diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 71bb8ab9a4..0000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,510 +0,0 @@ -version: 2.1 - -orbs: - aws-cli: circleci/aws-cli@4.1.1 - aws-ecs: circleci/aws-ecs@4.0.0 - opsgenie: opsgenie/opsgenie@1.0.8 - - -jobs: - frontend-code-test: - resource_class: large - docker: - - image: cimg/node:22.13.0 - working_directory: /home/circleci/tasking-manager - steps: - - checkout - - restore_cache: - keys: - - yarn-deps-{{ checksum "frontend/yarn.lock" }} - - run: - name: Install node dependencies - command: | - yarn --version - cd ${CIRCLE_WORKING_DIRECTORY}/frontend - yarn install - - save_cache: - key: yarn-deps-{{ checksum "frontend/yarn.lock" }} - paths: - - frontend/node_modules - - env - - run: - name: Run yarn test - no_output_timeout: 20m - command: | - cd ${CIRCLE_WORKING_DIRECTORY}/frontend/ - CI=true yarn test -w 1 --silent - CI=true GENERATE_SOURCEMAP=false yarn build - - backend-code-check-PEP8: - docker: - - image: cimg/python:3.10 - steps: - - checkout - - run: - name: flake8 tests - command: | - pip install flake8 - flake8 manage.py backend tests migrations - - backend-code-check-Black: - docker: - - image: cimg/python:3.10 - steps: - - checkout - - run: - name: black tests - command: | - pip install 'black==23.12.1' ## TODO: Update to version 24 - black --check manage.py backend tests migrations - - backend-functional-tests: - resource_class: large - docker: - - - image: cimg/python:3.10 - environment: - SQLALCHEMY_DATABASE_URI: postgresql://taskingmanager@localhost/test_tm - POSTGRES_TEST_DB: test_tm - POSTGRES_USER: taskingmanager - POSTGRES_ENDPOINT: localhost - TM_ORG_CODE: "CICode" - TM_ORG_NAME: "CircleCI Test Organisation" - - - image: cimg/postgres:14.9-postgis - environment: - POSTGRES_USER: taskingmanager - POSTGRES_DB: test_tm - - working_directory: /home/circleci/tasking_manager - steps: - - checkout - - run: sudo apt-get update - - run: sudo apt-get -y install libgeos-dev # Required for shapely - - run: sudo apt-get -y install proj-bin libproj-dev - - run: pip install --upgrade pip pdm==2.18.1 - - run: pdm export --dev --without-hashes > requirements.txt - - run: pip install -r requirements.txt - - run: mkdir --mode 766 -p /tmp/logs - - run: mkdir ${CIRCLE_WORKING_DIRECTORY}/tests/backend/results - - run: find ./tests/backend -name "test*.py" -exec chmod -x {} \; - - run: echo "export TM_LOG_DIR=/tmp/logs" >> $BASH_ENV - - run: coverage erase - - run: - name: Run backend functional tests - command: | - coverage run --source ./backend -m pytest \ - --rootdir ./tests/backend \ - --junit-xml ${CIRCLE_WORKING_DIRECTORY}/tests/backend/results/unitresults.xml - - run: coverage xml -o ${CIRCLE_WORKING_DIRECTORY}/tests/backend/results/coverage.xml - - store_test_results: - path: tests/backend/results/unitresults.xml - - store_artifacts: - path: tests/backend/results - - database-backup: - resource_class: large - parameters: - stack_name: - description: "Cloudformation stack name" - type: string - docker: - - image: cimg/postgres:15.4-postgis - steps: - - aws-cli/setup: - role_arn: "arn:aws:iam::$ORG_AWS_ACCOUNT_ID:role/CircleCI-OIDC-Connect" - profile_name: "OIDC-Profile" - role_session_name: "database-snapshot" - session_duration: "2700" - - run: - name: Find the instance ID of the database in the stack to backup - command: | - RDS_ID=$(aws rds describe-db-instances \ - --query 'DBInstances[?contains(TagList[].Key, `aws:cloudformation:stack-name`) && contains(TagList[].Value, `tasking-manager-<< parameters.stack_name >>`)].[DBInstanceIdentifier]' \ - --output text) - echo "export RDS_ID=$RDS_ID" >> $BASH_ENV - echo "RDS ID is: $RDS_ID" - - run: - name: Find Snapshot creation timestamp - command: | - # Given instance ID, find the timestamp of the latest snapshot - SNAPSHOT_TIMESTAMP=$(aws rds describe-db-snapshots \ - --db-instance-identifier=${RDS_ID} \ - --query="max_by(DBSnapshots, &SnapshotCreateTime).OriginalSnapshotCreateTime" \ - --output text) - - run: - name: Make Database Backup - command: | - aws rds wait db-instance-available \ - --db-instance-identifier ${RDS_ID} - # create new aws rds snapshot - printf -v time_now '%(%Y-%m-%d-%H-%M)T' -1 - aws rds create-db-snapshot \ - --db-snapshot-identifier tm4-<< parameters.stack_name >>-${RDS_ID}-${time_now} \ - --db-instance-identifier ${RDS_ID} - aws rds wait db-snapshot-completed \ - --db-snapshot-identifier tm4-<< parameters.stack_name >>-${RDS_ID}-${time_now} \ - --db-instance-identifier ${RDS_ID} - if [[ $? -eq 255 ]]; then - echo "Production snapshot creation failed. Exiting with exit-code 125" - exit 125 - fi - - run: - name: Check / validate backup - command: | - echo "TODO: BACKUP VALIDATION NOT IMPLEMENTED" - - backend_deploy: - parameters: - stack_name: - description: "the name of the stack for cfn-config" - type: string - gitsha: - description: "The 40 char hash of the git commit" - type: string - host_ami: - description: "AMI of the host instance" - type: string - pg_version: - description: "Engine version of PostgreSQL database" - type: string - default: "11.19" - pg_param_group: - description: "Parameter group for RDS PostgreSQL server" - type: string - default: "tm3-logging-postgres11" - db_instance_type: - description: "RDS DB Instance class for the backend database" - type: string - default: "db.t3.xlarge" - backend_instance_type: - description: "Backend EC2 Instance type" - type: string - default: "c6a.large" - working_directory: /home/circleci/tasking-manager - resource_class: medium - docker: - - image: cimg/node:22.13.0 - steps: - - checkout - - aws-cli/setup: - role_arn: "arn:aws:iam::$ORG_AWS_ACCOUNT_ID:role/CircleCI-OIDC-Connect" - profile_name: "OIDC-Profile" - role_session_name: "backend-deploy" - session_duration: "2700" - - run: sudo apt-get update - - run: sudo apt-get -y install libgeos-dev jq - - run: sudo yarn global add @mapbox/cfn-config @mapbox/cloudfriend - - run: - name: Download and patch Cloudformation parameter JSON file - command: | - tmpfile=$(mktemp) - aws s3 cp s3://hot-cfn-config/tasking-manager/tasking-manager-<< parameters.stack_name >>-${AWS_REGION}.cfn.json /tmp/tasking-manager.cfn.json - jq --compact-output \ - --arg GITSHA "<< parameters.gitsha >>" \ - --arg AMI "<< parameters.host_ami >>" \ - --arg PGVERSION "<< parameters.pg_version >>" \ - --arg DBTYPE "<< parameters.db_instance_type >>" \ - --arg EC2TYPE "<< parameters.backend_instance_type >>" \ - --arg DBPARAMG "<< parameters.pg_param_group >>" \ - '.GitSha = $GITSHA | .TaskingManagerBackendAMI = $AMI | .DatabaseEngineVersion = $PGVERSION | .DatabaseInstanceType = $DBTYPE | .DatabaseParameterGroupName = $DBPARAMG | .TaskingManagerBackendInstanceType = $EC2TYPE' \ - /tmp/tasking-manager.cfn.json > "$tmpfile" && mv "$tmpfile" $CIRCLE_WORKING_DIRECTORY/cfn-config-<< parameters.stack_name >>.json - - run: - name: Deploy to << parameters.stack_name >> - command: | - export NODE_PATH=/usr/local/share/.config/yarn/global/node_modules/ - validate-template $CIRCLE_WORKING_DIRECTORY/scripts/aws/cloudformation/tasking-manager.template.js - export JSON_CONFIG="$(< $CIRCLE_WORKING_DIRECTORY/cfn-config-<< parameters.stack_name >>.json)" - cfn-config update << parameters.stack_name >> $CIRCLE_WORKING_DIRECTORY/scripts/aws/cloudformation/tasking-manager.template.js -f -c hot-cfn-config -t hot-cfn-config -r $AWS_REGION -p "$JSON_CONFIG" - - backend_deploy_containers: - working_directory: /home/circleci/tasking-manager - docker: - - image: cimg/python:3.10.7 - steps: - - checkout - - aws-cli/setup: - role_arn: "arn:aws:iam::$ORG_AWS_ACCOUNT_ID:role/CircleCI-OIDC-Connect" - profile_name: "OIDC-Profile" - role_session_name: "backend-deploy-containers" - session_duration: "2700" - - run: sudo apt-get update - - run: sudo apt-get -y install curl - - run: echo "Run AWS Fargate" - - frontend_deploy: - working_directory: /home/circleci/tasking-manager - resource_class: large - docker: - - image: cimg/node:22.13.0 - parameters: - stack_name: - description: "the name of the stack for cfn-config" - type: string - steps: - - checkout - - aws-cli/setup: - role_arn: "arn:aws:iam::$ORG_AWS_ACCOUNT_ID:role/CircleCI-OIDC-Connect" - profile_name: "OIDC-Profile" - role_session_name: "frontend-deploy" - session_duration: "1800" - - run: - name: Deploy Frontend to S3 - command: | - cd ${CIRCLE_WORKING_DIRECTORY}/frontend/ - export TM_ENVIRONMENT=<< parameters.stack_name >> - yarn - CI=true GENERATE_SOURCEMAP=true yarn build - aws s3 sync build/ s3://tasking-manager-<< parameters.stack_name >>-react-app --delete --cache-control max-age=31536000 - aws s3 cp s3://tasking-manager-<< parameters.stack_name >>-react-app s3://tasking-manager-<< parameters.stack_name >>-react-app --recursive --exclude "*" --include "*.html" --metadata-directive REPLACE --cache-control no-cache --content-type text/html - export DISTRIBUTION_ID=`aws cloudformation list-exports --output=text --query "Exports[?Name=='tasking-manager-<< parameters.stack_name >>-cloudfront-id-${AWS_REGION}'].Value"` - aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*" - -workflows: - version: 2 - - production-all: - when: - and: - - equal: [ deployment/hot-tasking-manager, << pipeline.git.branch >> ] - jobs: - - database-backup: - name: Backup production database - stack_name: "tm4-production" - context: - - org-global - - tasking-manager-tm4-production - - frontend-code-test - - frontend_wait_for_approval: - type: approval - requires: - - frontend-code-test - - backend-functional-tests - - backend-code-check-PEP8 - - backend-code-check-Black - - backend_wait_for_approval: - type: approval - requires: - - Backup production database - - backend-functional-tests - - backend-code-check-PEP8 - - backend-code-check-Black - - backend_deploy: - name: Deploy backend production - gitsha: $CIRCLE_SHA1 - stack_name: "tm4-production" - host_ami: "/aws/service/debian/release/11/20240813-1838/amd64" - backend_instance_type: c6a.large - pg_version: "13.10" - pg_param_group: "default.postgres13" - db_instance_type: "db.t4g.2xlarge" - requires: - - backend_wait_for_approval - context: - - org-global - - tasking-manager-tm4-production - - frontend_deploy: - name: Deploy frontend production - stack_name: "tm4-production" - requires: - - frontend_wait_for_approval - context: - - org-global - - tasking-manager-tm4-production - - production-frontend-only: - when: - and: - - equal: [ deployment/hot-tasking-manager-frontend, << pipeline.git.branch >> ] - jobs: - - frontend-code-test - - frontend_wait_for_approval: - type: approval - requires: - - frontend-code-test - - frontend_deploy: - name: Deploy frontend production - stack_name: "tm4-production" - requires: - - frontend_wait_for_approval - context: - - org-global - - tasking-manager-tm4-production - - production-backend-only: - when: - and: - - equal: [ deployment/hot-tasking-manager-backend, << pipeline.git.branch >> ] - jobs: - - database-backup: - name: Backup production database - stack_name: "tm4-production" - context: - - org-global - - tasking-manager-tm4-production - - backend-functional-tests - - backend-code-check-PEP8 - - backend-code-check-Black - - backend_wait_for_approval: - type: approval - requires: - - Backup production database - - backend-functional-tests - - backend-code-check-PEP8 - - backend-code-check-Black - - backend_deploy: - name: Deploy backend production - gitsha: $CIRCLE_SHA1 - stack_name: "tm4-production" - host_ami: "/aws/service/debian/release/11/20240813-1838/amd64" - backend_instance_type: c6a.large - pg_version: "13.10" - pg_param_group: "default.postgres13" - db_instance_type: "db.t4g.2xlarge" - requires: - - backend_wait_for_approval - context: - - org-global - - tasking-manager-tm4-production - - teachosm-all: - when: - and: - - equal: [ deployment/teachosm-tasking-manager, << pipeline.git.branch >> ] - jobs: - - database-backup: - name: Backup TeachOSM database - stack_name: "teachosm" - context: - - org-global - - tasking-manager-teachosm - - backend-functional-tests - - backend_deploy: - name: Deploy TeachOSM Backend - gitsha: $CIRCLECI_SHA1 - stack_name: "teachosm" - host_ami: "/aws/service/debian/release/11/20240813-1838/amd64" - requires: - - backend-functional-tests - context: tasking-manager-teachosm - - frontend_deploy: - name: Deploy TeachOSM Frontend - stack_name: "teachosm" - requires: - - backend-functional-tests - context: tasking-manager-teachosm - - staging-all: - when: - and: - - not: - matches: - pattern: "^deployment/.*" - value: << pipeline.git.branch >> - - equal: [ main, << pipeline.git.branch >> ] - jobs: - - database-backup: - name: Backup staging database - stack_name: "staging" - requires: - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - context: - - org-global - - tasking-manager-staging - - frontend-code-test - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - - backend_deploy: - name: Deploy staging backend - gitsha: $CIRCLE_SHA1 - stack_name: "staging" - host_ami: "/aws/service/debian/release/11/20240813-1838/amd64" - pg_version: "14.8" - pg_param_group: "default.postgres14" - db_instance_type: "db.t4g.small" - backend_instance_type: "t3.medium" - requires: - - Backup staging database - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - context: - - org-global - - tasking-manager-staging - - frontend_deploy: - name: Deploy staging frontend - stack_name: "staging" - requires: - - frontend-code-test - context: - - org-global - - tasking-manager-staging - - development-all: - when: - and: - - not: - matches: - pattern: "^deployment/.*" - value: << pipeline.git.branch >> - - or: - ## - equal: [ develop, << pipeline.git.branch >> ] # Disabled while we use dev setup for e2e testing - - equal: [ dev-switch-to-sandbox, << pipeline.git.branch >> ] - jobs: - - database-backup: - name: Backup development database - stack_name: "dev" - requires: - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - context: - - org-global - - tasking-manager-dev - - frontend-code-test - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - - backend_deploy: - name: Deploy development backend - gitsha: $CIRCLE_SHA1 - stack_name: "dev" - host_ami: "/aws/service/debian/release/11/20240813-1838/amd64" - pg_version: "14.10" - pg_param_group: "default.postgres14" - db_instance_type: "db.t4g.small" - backend_instance_type: "t3.medium" - requires: - - Backup development database - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests - context: - - org-global - - tasking-manager-dev - - frontend_deploy: - name: Deploy development frontend - stack_name: "dev" - requires: - - frontend-code-test - context: - - org-global - - tasking-manager-dev - - build-only-all: - when: - not: - or: # don't run this workflow for deployment branches - - matches: - pattern: "^deployment/.*" - value: << pipeline.git.branch >> - - equal: [ develop, << pipeline.git.branch >> ] - - equal: [ main, << pipeline.git.branch >> ] - jobs: - - frontend-code-test - - backend-code-check-PEP8 - - backend-code-check-Black - - backend-functional-tests diff --git a/.circleci/rdsid.sh b/.circleci/rdsid.sh deleted file mode 100755 index d707b33db8..0000000000 --- a/.circleci/rdsid.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -ARNS=$(aws rds describe-db-instances --query "DBInstances[].DBInstanceArn" --output text --region us-east-1) -for line in $ARNS; do - TAGS=$(aws rds list-tags-for-resource --resource-name "$line" --query "TagList[]" --region us-east-1) - MATCHES=$(echo $TAGS | python -c "import sys, json; tags = json.loads('$1'); remote = {t['Key']: t['Value'] for t in json.load(sys.stdin)}; print('$line'.split(':')[-1]) if len({'$line' for k, v in tags.items() if k in remote and v == remote[k]}) > 0 else ''") - if [[ ! -z $MATCHES ]]; then - echo $MATCHES - fi -done diff --git a/.flaskenv b/.flaskenv deleted file mode 100644 index 7800b14d16..0000000000 --- a/.flaskenv +++ /dev/null @@ -1,5 +0,0 @@ -# This file is not read for Tasking Manager variables. Use tasking-manager.env instead. -# Flask (the web server framework) uses this file to figure out what the entry point is, -# if it is non-standard. -# For more information, see https://flask.palletsprojects.com/en/2.0.x/cli/#environment-variables-from-dotenv -FLASK_APP=manage.py diff --git a/.github/workflows/add-version-to-db.yml b/.github/workflows/add-version-to-db.yml index cce67fc3bb..1526d023cd 100644 --- a/.github/workflows/add-version-to-db.yml +++ b/.github/workflows/add-version-to-db.yml @@ -6,6 +6,6 @@ jobs: add_release: runs-on: ubuntu-latest steps: - - name: Add release version to db - run: | - curl -X POST "https://tasking-manager-tm4-production-api.hotosm.org/api/v2/system/release/" + - name: Add release version to db + run: | + curl -X POST "https://tasking-manager-production-api.hotosm.org/api/v2/system/release/" diff --git a/.github/workflows/backend-build-deploy.yml b/.github/workflows/backend-build-deploy.yml new file mode 100644 index 0000000000..87d8ca54f8 --- /dev/null +++ b/.github/workflows/backend-build-deploy.yml @@ -0,0 +1,80 @@ +name: Build and Deploy Backend to ECS + +on: + push: + branches: + - main + - staging + - develop + paths: + - "backend/**" + workflow_dispatch: + +env: + # NOTE: You can override these variables in the workflow file with GitHub Variables + # vars.TEAM is team name here in case you want to deploy for different team. Default is hotosm + # vars.INFRA_ENVIRONMENT is environment name here in case you have applied infrastructue with different environment name. Default is github.ref_name + + AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} + OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }} + ECS_CLUSTER: tasking-manager-${{ vars.INFRA_ENVIRONMENT || github.ref_name }}-cluster + TASK_DEFINITION_PREFIX: tasking-manager-${{ vars.TEAM || 'hotosm' }}-${{ vars.INFRA_ENVIRONMENT || github.ref_name }} + ECS_SERVICE_PREFIX: tasking-manager-${{ vars.TEAM || 'hotosm' }}-${{ vars.INFRA_ENVIRONMENT || github.ref_name }} + CONTAINER_NAME_PREFIX: tasking-manager-${{ vars.TEAM || 'hotosm' }}-${{ vars.INFRA_ENVIRONMENT || github.ref_name }} + +jobs: + image-build-and-push: + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.5.1 + with: + image_name: ghcr.io/${{ github.repository }}/backend + build_target: prod + dockerfile: scripts/docker/Dockerfile + image_tags: | + ghcr.io/${{ vars.IMAGE_NAME || 'hotosm/tasking-manager/backend' }}:${{ github.ref_name }} + ghcr.io/${{ vars.IMAGE_NAME || 'hotosm/tasking-manager/backend' }}:${{ github.sha }} + + deploy-service: + name: Deploy ${{ matrix.service }} to ECS + runs-on: ubuntu-latest + environment: ${{ github.ref_name }} + needs: image-build-and-push + permissions: + contents: read + id-token: write + + strategy: + matrix: + service: [fastapi, cron] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ env.OIDC_ROLE_ARN }} + role-session-name: gh-ci-ecs-deploy-${{ github.ref_name }}-${{ matrix.service }} + + - name: Download task definition + run: | + aws ecs describe-task-definition --region ${{ env.AWS_REGION }} \ + --task-definition ${{ env.TASK_DEFINITION_PREFIX }}-${{ matrix.service }} \ + --query taskDefinition > task-definition.json + + - name: Task definition rendition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: ${{ env.CONTAINER_NAME_PREFIX }}-${{ matrix.service }} + image: ghcr.io/${{ vars.IMAGE_NAME || 'hotosm/tasking-manager/backend' }}:${{ github.sha }} + + - name: Deploy task definition for ${{ matrix.service }} + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE_PREFIX }}-${{ matrix.service }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true diff --git a/.github/workflows/build_ci_img.yml b/.github/workflows/build_ci_img.yml new file mode 100644 index 0000000000..394206d977 --- /dev/null +++ b/.github/workflows/build_ci_img.yml @@ -0,0 +1,37 @@ +name: 🔧 Build CI Img + +on: + # Push includes PR merge + push: + branches: + - main + - staging + - development + paths: + # Workflow is triggered only if deps change + - "src/backend/pyproject.toml" + - "src/backend/Dockerfile" + # Allow manual trigger + workflow_dispatch: + +jobs: + backend-ci-build: + uses: hotosm/gh-workflows/.github/workflows/image_build.yml@3.2.0 + with: + context: . + build_target: ci + image_tags: | + "ghcr.io/${{ github.repository }}/backend:ci-${{ github.ref_name }}" + + invalidate-cache: + runs-on: ubuntu-latest + steps: + - name: Delete CI Img Cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh extension install actions/gh-actions-cache + gh actions-cache delete image-cache-${{ runner.os }} \ + -R ${{ github.repository }} \ + -B ${{ github.ref_name }} \ + --confirm || true diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml deleted file mode 100644 index 3e4558c2c2..0000000000 --- a/.github/workflows/container-image.yml +++ /dev/null @@ -1,69 +0,0 @@ -# -name: Build & publish TM backend container image - -on: - push: - branches: - - main - - develop - - deployment/hot-tasking-manager - - deployment/demo-tasking-manager - - deployment/container-tasking-manager - - pull_request: - branches: - - main - - deployment/hot-tasking-manager - - deployment/demo-tasking-manager - - deployment/container-tasking-manager - - -env: - REGISTRY: ghcr.io - IMAGE_NAME: hotosm/tasking-manager-backend # was ${{ github.repository }} - - -jobs: - build-and-push-image: - runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. - permissions: - contents: read - packages: write - - steps: - - name: Setup QEMU - uses: docker/setup-qemu-action@v3 - - - name: Setup Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - - name: Set container image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - tags: | - type=ref,event=tag - type=ref,event=branch - type=semver,pattern=raw - type=semver,pattern={{version}} - type=semver,pattern={{major}} - - - name: Build and push container image - uses: docker/build-push-action@v5 - with: - context: "{{defaultContext}}" - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image-push.yml b/.github/workflows/docker-image-push.yml deleted file mode 100644 index dcdc178249..0000000000 --- a/.github/workflows/docker-image-push.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build & publish TM backend container image - -on: - push: - branches: - - main - - develop - - deployment/hot-tasking-manager - - deployment/demo-tasking-manager - - deployment/container-tasking-manager - - pull_request: - branches: - - main - - deployment/hot-tasking-manager - - deployment/demo-tasking-manager - - deployment/container-tasking-manager - - -jobs: - backend-build: - uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.5.1 - with: - image_name: ghcr.io/${{ github.repository }}/backend - build_target: prod - - # frontend-build: - # uses: hotosm/gh-workflows/.github/workflows/image_build.yml@1.5.1 - # with: - # image_name: ghcr.io/${{ github.repository }}/backend - # dockerfile: scripts/docker/Dockerfile.frontend diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..73ebd2d9d6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,36 @@ +name: 📖 Publish Docs + +on: + push: + paths: + - docs/** + - src/** + - mkdocs.yml + branches: + - develop + workflow_dispatch: + +jobs: + # TODO need to also publish osm-fieldwork docs somewhere... + + build_doxygen: + uses: hotosm/gh-workflows/.github/workflows/doxygen_build.yml@3.2.0 + with: + output_path: docs/apidocs + + build_openapi_json: + uses: hotosm/gh-workflows/.github/workflows/openapi_build.yml@3.2.1 + with: + image: ghcr.io/${{ github.repository }}/backend:ci-${{ github.ref_name }} + example_env_file_path: "example.env" + output_path: docs/openapi.json + py_backend_app_context: backend.main + + publish_docs: + uses: hotosm/gh-workflows/.github/workflows/mkdocs_build.yml@3.2.0 + needs: + - build_doxygen + - build_openapi_json + with: + doxygen: true + openapi: true diff --git a/.github/workflows/ecs-deploy.yml b/.github/workflows/ecs-deploy.yml deleted file mode 100644 index 25e355e557..0000000000 --- a/.github/workflows/ecs-deploy.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Deploy to Amazon ECS - -on: - push: - branches: - - tasking-manager-fastapi - -env: - REGISTRY: ghcr.io - AWS_REGION: us-east-1 - ECS_CLUSTER: tasking-manager - ECS_SERVICE: tasking-manager-fastAPI - CONTAINER_NAME: backend - IMAGE_NAME: hotosm/tasking-manager-backend # was ${{ github.repository }} - -jobs: - build-push-image: - name: Build Images - runs-on: ubuntu-latest - environment: production - - permissions: - contents: read - packages: write - - outputs: - imageid: steps.build-push-image.imageid - - steps: - - name: Setup QEMU - uses: docker/setup-qemu-action@v3 - - - name: Setup Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set container image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - tags: | - type=ref,event=branch - - - name: Build and push container image - id: build-push-image - uses: docker/build-push-action@v5 - with: - context: "{{defaultContext}}" - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - - deploy: - name: Deploy - runs-on: ubuntu-latest - environment: production - - permissions: - contents: read - id-token: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-region: us-east-1 - role-to-assume: arn:aws:iam::670261699094:role/Github-AWS-OIDC - role-session-name: gh-ci-ecs-deploy - - - name: Download task definition - run: | - aws ecs describe-task-definition --task-definition tasking-manager --query taskDefinition > task-definition.json - - - name: Task definition rendition - id: task-def - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: task-definition.json - container-name: ${{ env.CONTAINER_NAME }} - image: ${{ needs.build-push-image.outputs.imageid }} - - - name: Deploy task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.task-def.outputs.task-definition }} - service: ${{ env.ECS_SERVICE }} - cluster: ${{ env.ECS_CLUSTER }} - wait-for-service-stability: true diff --git a/.github/workflows/frontend-build-deploy.yml b/.github/workflows/frontend-build-deploy.yml new file mode 100644 index 0000000000..a770be29c3 --- /dev/null +++ b/.github/workflows/frontend-build-deploy.yml @@ -0,0 +1,113 @@ +name: Build and Deploy Frontend to Cloudfront + +on: + push: + branches: + - main + - staging + - develop + paths: + - "frontend/**" + workflow_dispatch: + +jobs: + build: + name: Build Static Files + runs-on: ubuntu-latest + + environment: + name: ${{ github.ref_name == 'develop' && 'develop' || github.ref_name == 'staging' && 'staging' || github.ref_name == 'main' && 'main' || 'develop' }} + url: ${{ github.ref_name == 'develop' && 'https://tasks-dev.hotosm.org' || github.ref_name == 'staging' && 'https://tasks-stage.hotosm.org' || github.ref_name == 'main' && 'https://tasks.hotosm.org' || 'https://just.build.test' }} + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: frontend/node_modules + key: tm-fe-${{ runner.os }}-build-${{ hashFiles('frontend/package.json') }} + restore-keys: | + tm-fe-${{ runner.os }}-build-${{ hashFiles('frontend/package.json') }} + + - name: Install yarn + run: npm install -g yarn + + - name: Load Environment from GitHub variables & secrets. + uses: hotosm/gh-workflows/.github/actions/vars_n_secret_to_env@env_substitute/1.0.0 + with: + vars_context: ${{ toJson(vars) }} + secrets_context: ${{ toJson(secrets) }} + + - name: Create .env file + uses: hotosm/gh-workflows/.github/actions/env_substitute@env_substitute/1.0.0 + with: + working_directory: ./frontend + template_dotenv: .env.expand + output_file: .env + + - name: Install dependencies + working-directory: ./frontend + run: yarn install + + - name: Generate build + working-directory: ./frontend + run: | + yarn build + + - name: Upload Builds Artifacts + uses: actions/upload-artifact@v4 + with: + name: tm-fe-${{ github.sha }} + path: ./frontend/build + retention-days: 90 + + deploy: + name: Deploy static files + needs: + - build + runs-on: ubuntu-latest + + environment: + name: ${{ github.ref_name == 'develop' && 'develop' || github.ref_name == 'staging' && 'staging' || github.ref_name == 'main' && 'main' || 'develop' }} + url: ${{ github.ref_name == 'develop' && 'https://tasks-dev.hotosm.org' || github.ref_name == 'staging' && 'https://tasks-stage.hotosm.org' || github.ref_name == 'main' && 'https://tasks.hotosm.org' || 'https://just.build.test' }} + + permissions: + contents: read + id-token: write + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Download Build Artifacts + uses: actions/download-artifact@v4 + with: + name: tm-fe-${{ github.sha }} + path: ./build + + - name: Setup AWS Credentials + uses: hotosm/gh-workflows/.github/actions/configure_aws_credentials@configure_aws_credentials/1.0.0 + with: + USE_OIDC_FOR_AWS: true + AWS_CONFIG_FILE_PATH: ${{ github.workspace }}/.aws/credentials + AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }} + AWS_REGION: ${{ vars.AWS_REGION }} + + - name: Copy static files to S3 + shell: bash + run: | + set -ex + aws s3 cp --recursive ./build s3://${{ vars.FRONTEND_S3_BUCKET }} + + - name: Create cloudfront redistribution + shell: bash + run: | + set -ex + aws cloudfront create-invalidation --distribution-id ${{ vars.FRONTEND_CLOUDFRONT_DISTRIBUTION_ID }} --paths / diff --git a/.github/workflows/pr_test_backend.yml b/.github/workflows/pr_test_backend.yml new file mode 100644 index 0000000000..acd2b6efa0 --- /dev/null +++ b/.github/workflows/pr_test_backend.yml @@ -0,0 +1,54 @@ +name: 🧪 PR Test Backend + +on: + pull_request: + branches: + - main + - staging + - develop + paths: + - "backend/**" + workflow_dispatch: + +jobs: + code-check-PEP8: + name: Run PEP8 code style checks + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + pip install black==25.1.0 + + - name: Run PEP8 checks + run: | + flake8 manage.py backend tests migrations + black --check manage.py backend tests migrations + + pytest: + uses: hotosm/gh-workflows/.github/workflows/test_compose.yml@3.2.0 + with: + image_name: ghcr.io/${{ github.repository }}/backend + build_context: . + build_dockerfile: ./scripts/docker/Dockerfile + pre_command: | + cp example.env tasking-manager.env + docker compose up -d tm-db tm-backend tm-migration tm-cron-jobs traefik + docker compose exec tm-db bash -c "PGPASSWORD=tm psql -U tm -d tasking-manager <> $GITHUB_OUTPUT + echo "INFRA_BRANCH_NAME=${INFRA_BRANCH_NAME}" >> $GITHUB_OUTPUT + echo "INFRA_BRANCH_URL=${INFRA_BRANCH_URL}" >> $GITHUB_OUTPUT + + plan: + name: Terragrunt Plan + uses: hotosm/gh-workflows/.github/workflows/terragrunt-plan.yml@3.1.2 + permissions: + id-token: write + contents: read + needs: + - get_deployment_meta + with: + working_dir: ./scripts/aws/infra/${{ needs.get_deployment_meta.outputs.INFRA_BRANCH_NAME }}/${{ github.event.inputs.module_path }} + terraform_version: "1.9.5" + terragrunt_version: "0.67.15" + aws_region: us-east-1 + load_env: true + environment_name: ${{ needs.get_deployment_meta.outputs.INFRA_ENV_NAME }} + environment_url: ${{ needs.get_deployment_meta.outputs.INFRA_BRANCH_URL }} + encrypt_plan_file: true + secrets: inherit + + manual_apply_approval: + name: Manual Approval for Apply + runs-on: ubuntu-latest + needs: + - plan + - get_deployment_meta + if: ${{ success() }} + environment: + name: ${{ needs.get_deployment_meta.outputs.INFRA_ENV_NAME }}-approval + url: ${{ needs.get_deployment_meta.outputs.INFRA_BRANCH_URL }} + steps: + - name: Manual approval step + run: echo "Plan approved manually, proceeding to apply" + + manual_apply: + name: Terragrunt Apply After Manual Approval + uses: hotosm/gh-workflows/.github/workflows/terragrunt-apply.yml@3.1.2 + permissions: + id-token: write + contents: read + needs: + - get_deployment_meta + - manual_apply_approval + with: + working_dir: ./scripts/aws/infra/${{ needs.get_deployment_meta.outputs.INFRA_BRANCH_NAME }}/${{ github.event.inputs.module_path }} + terraform_version: "1.9.5" + terragrunt_version: "0.67.15" + aws_region: us-east-1 + load_env: true + plan_file_name: ${{ github.event.repository.name }}-${{ github.run_id }}-${{github.run_attempt}} + use_gh_artifacts: true + environment_name: ${{ needs.get_deployment_meta.outputs.INFRA_ENV_NAME }} + environment_url: ${{ needs.get_deployment_meta.outputs.INFRA_BRANCH_URL }} + decrypt_plan_file: true + secrets: inherit diff --git a/.gitignore b/.gitignore index 556a135d51..66e1c75544 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ - # Configuration files # tasking-manager*.env +.env # Generated frontend # frontend/node_modules/ @@ -65,6 +65,17 @@ htmlcov/ # Terragrunt .terragrunt-cache +**/.terragrunt-cache/ +**/.terragrunt-cache +**/infra.env +temporary-dep-hack.hcl # Docker & Docker compose docker-compose.override.yml +postgres_data/ + +# Variables +scripts/aws/infra/staging/variables.py + +# Files +tests/test_db_dump/tm_sample_db.sql diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f50a0f193f..368a5110aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace name: trim trailing whitespace @@ -8,7 +8,7 @@ repos: entry: trailing-whitespace-fixer language: python types: [text] - stages: [commit, push, manual] + stages: [pre-commit, pre-push, manual] - id: end-of-file-fixer name: fix end of files @@ -16,7 +16,7 @@ repos: entry: end-of-file-fixer language: python types: [text] - stages: [commit, push, manual] + stages: [pre-commit, pre-push, manual] - id: detect-aws-credentials name: detect aws credentials @@ -60,28 +60,28 @@ repos: description: prevents giant files from being committed. entry: check-added-large-files language: python - stages: [commit, push, manual] + stages: [pre-commit, pre-push, manual] args: ['--maxkb=10240'] # Versioning: Commit messages & changelog - repo: https://github.com/commitizen-tools/commitizen - rev: v3.29.0 + rev: v4.6.1 hooks: - id: commitizen stages: [commit-msg] - # Lint / autoformat: Python code - - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: "v0.6.4" - hooks: - # Run the linter - - id: ruff - files: ^backend/(?:.*/)*.*$ - args: [--fix, --exit-non-zero-on-fix] - # Run the formatter - - id: ruff-format - files: ^backend/(?:.*/)*.*$ + # # Lint / autoformat: Python code + # - repo: https://github.com/astral-sh/ruff-pre-commit + # # Ruff version. + # rev: "v0.6.4" + # hooks: + # # Run the linter + # - id: ruff + # files: ^backend/(?:.*/)*.*$ + # args: [--fix, --exit-non-zero-on-fix] + # # Run the formatter + # - id: ruff-format + # files: ^backend/(?:.*/)*.*$ # INFO: Searches for code that is used or lingering around. (Disabled since there were a lot of work from dev end to remove stuff) # - repo: https://github.com/asottile/dead @@ -104,3 +104,40 @@ repos: # "!frontend/pnpm-lock.yaml", # "!backend/tests/test_data/**", # ] + + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3.10 + + + - repo: https://github.com/PyCQA/flake8 + rev: "7.2.0" + hooks: + - id: flake8 + name: flake8 + additional_dependencies: [mccabe>=0.7.0] + args: + [ + "--max-line-length=119", + "--max-complexity=150", + "--ignore=E203,W503", + "--extend-exclude=migrations/*", + ] + files: '^(backend|tests|manage\.py)' + + # - repo: https://github.com/psf/black + # rev: "23.12.1" # Please keep this version updated, should be same as your black version + # hooks: + # - id: black + # name: black tests + # entry: black + # args: + # [ + # "--line-length=88", + # "manage.py", + # "backend", + # "tests", + # "migrations", + # ] diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9b3a7ede9e..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,107 +0,0 @@ -ARG DEBIAN_IMG_TAG=slim-bookworm -ARG PYTHON_IMG_TAG=3.10 - - - -FROM docker.io/python:${PYTHON_IMG_TAG}-${DEBIAN_IMG_TAG} as base -ARG APP_VERSION=0.1.0 -ARG DOCKERFILE_VERSION=0.5.0 -ARG ALPINE_IMG_TAG -ARG DEBIAN_IMG_TAG -ARG PYTHON_IMG_TAG -ARG MAINTAINER=sysadmin@hotosm.org -LABEL org.hotosm.tasks.app-version="${APP_VERSION}" \ - org.hotosm.tasks.debian-img-tag="${DEBIAN_IMG_TAG}" \ - org.hotosm.tasks.python-img-tag="${PYTHON_IMG_TAG}" \ - org.hotosm.tasks.dockerfile-version="${DOCKERFILE_VERSION}" \ - org.hotosm.tasks.maintainer="${MAINTAINER}" \ - org.hotosm.tasks.api-port="5000" -# Fix timezone (do not change - see issue #3638) -ENV TZ UTC -# Add non-root user, permissions, init log dir -RUN useradd --uid 9000 --create-home --home /home/appuser --shell /bin/false appuser - - - - -FROM base as extract-deps -RUN pip install --no-cache-dir --upgrade pip -WORKDIR /opt/python -COPY pyproject.toml pdm.lock README.md /opt/python/ -RUN pip install --no-cache-dir pdm==2.18.1 -RUN pdm export --prod --without-hashes > requirements.txt - - - -FROM base as build -RUN pip install --no-cache-dir --upgrade pip -WORKDIR /opt/python -# Setup backend build-time dependencies -RUN apt-get update && apt-get install --no-install-recommends -y \ - build-essential \ - libffi-dev \ - libgeos-dev \ - postgresql-server-dev-15 \ - python3-dev \ - && rm -rf /var/lib/apt/lists/* -# Setup backend Python dependencies -COPY --from=extract-deps \ - /opt/python/requirements.txt /opt/python/ -USER appuser:appuser -RUN pip install --user --no-warn-script-location \ - --no-cache-dir -r /opt/python/requirements.txt - - - -FROM base as runtime -ARG PYTHON_IMG_TAG -WORKDIR /usr/src/app -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONFAULTHANDLER=1 \ - PATH="/home/appuser/.local/bin:$PATH" \ - PYTHONPATH="/usr/src/app:$PYTHONPATH" \ - PYTHON_LIB="/home/appuser/.local/lib/python$PYTHON_IMG_TAG/site-packages" \ - SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ - REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt -# Setup backend runtime dependencies -RUN apt-get update && \ - apt-get install --no-install-recommends -y \ - libgeos3.11.1 postgresql-client proj-bin && \ - rm -rf /var/lib/apt/lists/* -COPY --from=build \ - /home/appuser/.local \ - /home/appuser/.local -USER appuser:appuser -COPY backend backend/ -COPY migrations migrations/ -COPY scripts/world scripts/world/ -COPY scripts/database scripts/database/ -COPY manage.py . - - - -FROM runtime as debug -RUN pip install --user --no-warn-script-location \ - --no-cache-dir debugpy==1.8.5 -EXPOSE 5678/tcp -CMD ["python", "-m", "debugpy", "--wait-for-client", "--listen", "0.0.0.0:5678", \ - "-m", "gunicorn", "-c", "python:backend.gunicorn", "manage:application", \ - "--reload", "--log-level", "error"] - - - -FROM runtime as prod -USER root -RUN apt-get update && \ - apt-get install -y curl && \ - rm -rf /var/lib/apt/lists/* -# Pre-compile packages to .pyc (init speed gains) -RUN python -c "import compileall; compileall.compile_path(maxlevels=10, quiet=1)" -RUN python -m compileall . -EXPOSE 5000/tcp -USER appuser:appuser -# Default gunicorn worker count is 1 -# For prod the WEB_CONCURRENCY env var can be used to set this -CMD ["gunicorn", "-c", "python:backend.gunicorn", "manage:application", \ - "--log-level", "error"] diff --git a/README.md b/README.md index 6bce05bf0b..f28d0e4128 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,12 @@ Status | Feature | Release -------|---------|--------- ✅ | Up-to-date OSM Statistics: Integrated with [ohsome Now](https://stats.now.ohsome.org/) for real-time data insights.| Released in [v4.6.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.6.2). ✅ | Downloadable OSM Exports: Export data directly from each project. | Available in[ v4.7.0](https://github.com/hotosm/tasking-manager/releases/tag/v4.7.0). -✅ | Live Data Quality Monitoring: Monitor project data quality in real-time. | Introduced in [v4.7.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.7.2). -✅ | Rapid Editor Upgrade: Enhanced mapping experience with the latest rapid editor updates. -✅ | Public-Facing Partner Pages: Create and display dedicated pages for partners running remote mapathons. -✅ | Downloadable Project List View: Allow users to explore projects via a downloadable list. [View issue](https://github.com/hotosm/tasking-manager/issues/3394). -✅ | MapSwipe Stats Integration: Display MapSwipe statistics on Partner Pages. -✅ | iD Editor Latest Features: Integrate the newest features of the iD editor. -🔄 | FastAPI Migration: Improve performance and scalability of Tasking Manager to handle large scale validation and mapping efforts. +✅ | Rapid Editor Upgrade: Enhanced mapping experience with the latest rapid editor updates.| Last updated in [v4.8.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.8.2) +✅ | Public-Facing Partner Pages: Create and display dedicated pages for partners running remote mapathons.| [v4.8.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.8.2) +✅ | Downloadable Project List View: Allow users to explore projects via a downloadable list. [View issue](https://github.com/hotosm/tasking-manager/issues/3394).| [v4.8.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.8.2) +✅ | MapSwipe Stats Integration: Display MapSwipe statistics on Partner Pages.|[v4.8.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.8.2) +✅ | iD Editor Latest Features: Integrate the newest features of the iD editor.|[v5.0.5](https://github.com/hotosm/tasking-manager/releases/tag/v5.0.5) +✅ | FastAPI Migration: Improve performance and scalability of Tasking Manager to handle large scale validation and mapping efforts.| [v5 launch 🎉](https://github.com/hotosm/tasking-manager/releases/tag/v5.0.0) 🔄 | Super Mapper: Redefine Mapper Level Milestones 🔄 | OSM Practice Projects: Enable users to engage in OSM practice projects within Tasking Manager workflow. 📅 | Expanding Project Types beyond basemap features diff --git a/backend/__init__.py b/backend/__init__.py index 9953912aae..8004ba65b6 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -13,20 +13,14 @@ logging.warning("Not using gevent") logging.info(e) -import os import json +import os from logging.handlers import RotatingFileHandler -from flask import Flask, redirect, request -from flask_cors import CORS -from flask_migrate import Migrate +from fastapi_mail import ConnectionConfig, FastMail from requests_oauthlib import OAuth2Session -from flask_restful import Api -from flask_sqlalchemy import SQLAlchemy -from flask_mail import Mail - -from backend.config import EnvironmentConfig +from backend.config import settings # Load error_messages.json and store it so that it is loaded only once at startup (Used in exceptions.py) # Construct the path to the JSON file @@ -37,133 +31,37 @@ ERROR_MESSAGES = json.load(jsonfile) -def sentry_init(): - """Initialize sentry.io event tracking""" - import sentry_sdk - from sentry_sdk.integrations.flask import FlaskIntegration - from backend.exceptions import ( - BadRequest, - NotFound, - Unauthorized, - Forbidden, - Conflict, - ) - - sentry_sdk.init( - dsn=EnvironmentConfig.SENTRY_BACKEND_DSN, - environment=EnvironmentConfig.ENVIRONMENT, - integrations=[FlaskIntegration()], - traces_sample_rate=0.1, - ignore_errors=[ - BadRequest, - NotFound, - Unauthorized, - Forbidden, - Conflict, - ], # Ignore these errors as they are handled by the API - ) - - def format_url(endpoint): parts = endpoint.strip("/") - return "/api/{}/{}/".format(EnvironmentConfig.API_VERSION, parts) - - -db = SQLAlchemy() -migrate = Migrate() + return "/api/{}/{}/".format(settings.API_VERSION, parts) + + +# Define the email configuration +conf = ConnectionConfig( + MAIL_USERNAME=settings.MAIL_USERNAME, + MAIL_PASSWORD=settings.MAIL_PASSWORD, + MAIL_FROM=settings.MAIL_DEFAULT_SENDER, + MAIL_PORT=settings.MAIL_PORT, + MAIL_SERVER=settings.MAIL_SERVER, + MAIL_FROM_NAME=settings.ORG_NAME, + MAIL_SSL_TLS=False, + MAIL_STARTTLS=True, + VALIDATE_CERTS=True, +) -mail = Mail() +mail = FastMail(conf) osm = OAuth2Session( - client_id=EnvironmentConfig.OAUTH_CLIENT_ID, - scope=EnvironmentConfig.OAUTH_SCOPE, - redirect_uri=EnvironmentConfig.OAUTH_REDIRECT_URI, + client_id=settings.OAUTH_CLIENT_ID, + scope=settings.OAUTH_SCOPE, + redirect_uri=settings.OAUTH_REDIRECT_URI, ) # Import all models so that they are registered with SQLAlchemy from backend.models.postgis import * # noqa -def create_app(env="backend.config.EnvironmentConfig"): - """ - Bootstrap function to initialise the Flask app and config - :return: Initialised Flask app - """ - # If SENTRY_BACKEND_DSN is configured, init sentry_sdk tracking - if EnvironmentConfig.SENTRY_BACKEND_DSN: - sentry_init() - - app = Flask(__name__, template_folder="services/messaging/templates/") - - # Load configuration options from environment - # Set env to TestEnvironmentConfig if TM_ENVIRONMENT is test - if os.getenv("TM_ENVIRONMENT") == "test": - env = "backend.config.TestEnvironmentConfig" - app.config.from_object(env) - # Enable logging to files - initialise_logger(app) - app.logger.info("Starting up a new Tasking Manager application") - - # Connect to database - app.logger.debug("Connecting to the database") - db.init_app(app) - migrate.init_app(app, db) - mail.init_app(app) - - app.logger.debug("Add root redirect route") - - @app.errorhandler(Exception) - def handle_generic_error(error): - """Generic error handler for all exceptions""" - from backend.exceptions import format_sub_code - - app.logger.exception(error) - - error_message = ( - str(error) - if len(str(error)) > 0 - else ERROR_MESSAGES["INTERNAL_SERVER_ERROR"] - ) - error_code = error.code if hasattr(error, "code") else 500 - error_sub_code = ( - format_sub_code(error.name) - if hasattr(error, "name") - else "INTERNAL_SERVER_ERROR" - ) - return ( - { - "error": { - "code": error_code, - "sub_code": error_sub_code, - "message": error_message, - "details": { - "url": request.url, - "method": request.method, - }, - } - }, - error_code, - ) - - @app.route("/") - def index_redirect(): - return redirect(format_url("system/heartbeat/"), code=302) - - # Add paths to API endpoints - add_api_endpoints(app) - - # Enables CORS on all API routes, meaning API is callable from anywhere - CORS(app) - - # Add basic oauth setup - app.secret_key = app.config[ - "SECRET_KEY" - ] # Required by itsdangerous, Flask-OAuthlib for creating entropy - - return app - - def initialise_logger(app): """ Read environment config then initialise a 2MB rotating log. Prod Log Level can be reduced to help diagnose Prod @@ -193,844 +91,3 @@ def initialise_counters(app): with app.app_context(): StatsService.get_homepage_stats() - - -def add_api_endpoints(app): - """ - Define the routes the API exposes using Flask-Restful. - """ - app.logger.debug("Adding routes to API endpoints") - api = Api(app) - - # Projects API import - from backend.api.projects.resources import ( - ProjectsRestAPI, - ProjectsAllAPI, - ProjectsQueriesBboxAPI, - ProjectsQueriesOwnerAPI, - ProjectsQueriesTouchedAPI, - ProjectsQueriesSummaryAPI, - ProjectsQueriesNoGeometriesAPI, - ProjectsQueriesNoTasksAPI, - ProjectsQueriesAoiAPI, - ProjectsQueriesPriorityAreasAPI, - ProjectsQueriesFeaturedAPI, - ProjectQueriesSimilarProjectsAPI, - ProjectQueriesActiveProjectsAPI, - ) - from backend.api.projects.activities import ( - ProjectsActivitiesAPI, - ProjectsLastActivitiesAPI, - ) - from backend.api.projects.contributions import ( - ProjectsContributionsAPI, - ProjectsContributionsQueriesDayAPI, - ) - from backend.api.projects.statistics import ( - ProjectsStatisticsAPI, - ProjectsStatisticsQueriesUsernameAPI, - ProjectsStatisticsQueriesPopularAPI, - ) - from backend.api.projects.teams import ProjectsTeamsAPI - from backend.api.projects.campaigns import ProjectsCampaignsAPI - from backend.api.projects.actions import ( - ProjectsActionsTransferAPI, - ProjectsActionsMessageContributorsAPI, - ProjectsActionsFeatureAPI, - ProjectsActionsUnFeatureAPI, - ProjectsActionsSetInterestsAPI, - ProjectActionsIntersectingTilesAPI, - ) - - from backend.api.projects.favorites import ProjectsFavoritesAPI - from backend.api.projects.partnerships import ( - ProjectPartnershipsRestApi, - PartnersByProjectAPI, - ) - - # Partner statistics API - from backend.api.partners.statistics import ( - GroupPartnerStatisticsAPI, - FilteredPartnerStatisticsAPI, - ) - - # Tasks API import - from backend.api.tasks.resources import ( - TasksRestAPI, - TasksQueriesJsonAPI, - TasksQueriesXmlAPI, - TasksQueriesGpxAPI, - TasksQueriesAoiAPI, - TasksQueriesMappedAPI, - TasksQueriesOwnInvalidatedAPI, - ) - from backend.api.tasks.actions import ( - TasksActionsMappingLockAPI, - TasksActionsMappingStopAPI, - TasksActionsMappingUnlockAPI, - TasksActionsMappingUndoAPI, - TasksActionsValidationLockAPI, - TasksActionsValidationStopAPI, - TasksActionsValidationUnlockAPI, - TasksActionsMapAllAPI, - TasksActionsValidateAllAPI, - TasksActionsInvalidateAllAPI, - TasksActionsResetBadImageryAllAPI, - TasksActionsResetAllAPI, - TasksActionsSplitAPI, - TasksActionsExtendAPI, - TasksActionsReverUserTaskstAPI, - ) - from backend.api.tasks.statistics import ( - TasksStatisticsAPI, - ) - - # Comments API impor - from backend.api.comments.resources import ( - CommentsProjectsRestAPI, - CommentsProjectsAllAPI, - CommentsTasksRestAPI, - ) - - # Annotations API import - from backend.api.annotations.resources import AnnotationsRestAPI - - # Issues API import - from backend.api.issues.resources import IssuesRestAPI, IssuesAllAPI - - # Interests API import - from backend.api.interests.resources import InterestsRestAPI, InterestsAllAPI - - # Licenses API import - from backend.api.licenses.resources import LicensesRestAPI, LicensesAllAPI - from backend.api.licenses.actions import LicensesActionsAcceptAPI - - # Campaigns API endpoint - from backend.api.campaigns.resources import CampaignsRestAPI, CampaignsAllAPI - - # Partners API import - from backend.api.partners.resources import ( - PartnerRestAPI, - PartnersAllRestAPI, - PartnerPermalinkRestAPI, - ) - - # Organisations API endpoint - from backend.api.organisations.resources import ( - OrganisationsStatsAPI, - OrganisationsRestAPI, - OrganisationsBySlugRestAPI, - OrganisationsAllAPI, - ) - from backend.api.organisations.campaigns import OrganisationsCampaignsAPI - - # Countries API endpoint - from backend.api.countries.resources import CountriesRestAPI - - # Teams API endpoint - from backend.api.teams.resources import ( - TeamsRestAPI, - TeamsAllAPI, - TeamsJoinRequestAPI, - ) - from backend.api.teams.actions import ( - TeamsActionsJoinAPI, - TeamsActionsAddAPI, - TeamsActionsLeaveAPI, - TeamsActionsMessageMembersAPI, - ) - - # Notifications API endpoint - from backend.api.notifications.resources import ( - NotificationsRestAPI, - NotificationsAllAPI, - NotificationsQueriesCountUnreadAPI, - NotificationsQueriesPostUnreadAPI, - ) - from backend.api.notifications.actions import ( - NotificationsActionsDeleteMultipleAPI, - NotificationsActionsDeleteAllAPI, - NotificationsActionsMarkAsReadAllAPI, - NotificationsActionsMarkAsReadMultipleAPI, - ) - - # Users API endpoint - from backend.api.users.resources import ( - UsersRestAPI, - UsersAllAPI, - UsersQueriesUsernameAPI, - UsersQueriesUsernameFilterAPI, - UsersQueriesOwnLockedAPI, - UsersQueriesOwnLockedDetailsAPI, - UsersQueriesFavoritesAPI, - UsersQueriesInterestsAPI, - UsersRecommendedProjectsAPI, - ) - from backend.api.users.tasks import UsersTasksAPI - from backend.api.users.actions import ( - UsersActionsSetUsersAPI, - UsersActionsSetLevelAPI, - UsersActionsSetRoleAPI, - UsersActionsSetExpertModeAPI, - UsersActionsVerifyEmailAPI, - UsersActionsRegisterEmailAPI, - UsersActionsSetInterestsAPI, - ) - from backend.api.users.openstreetmap import UsersOpenStreetMapAPI - from backend.api.users.statistics import ( - UsersStatisticsAPI, - UsersStatisticsInterestsAPI, - UsersStatisticsAllAPI, - OhsomeProxyAPI, - ) - - # System API endpoint - from backend.api.system.general import ( - SystemDocsAPI, - SystemHeartbeatAPI, - SystemLanguagesAPI, - SystemContactAdminRestAPI, - SystemReleaseAPI, - ) - from backend.api.system.banner import SystemBannerAPI - from backend.api.system.statistics import SystemStatisticsAPI - from backend.api.system.authentication import ( - SystemAuthenticationEmailAPI, - SystemAuthenticationLoginAPI, - SystemAuthenticationCallbackAPI, - ) - from backend.api.system.applications import SystemApplicationsRestAPI - from backend.api.system.image_upload import SystemImageUploadRestAPI - - # Projects REST endpoint - api.add_resource(ProjectsAllAPI, format_url("projects/"), methods=["GET"]) - api.add_resource( - ProjectsRestAPI, - format_url("projects/"), - endpoint="create_project", - methods=["POST"], - ) - api.add_resource( - ProjectsRestAPI, - format_url("projects//"), - methods=["GET", "PATCH", "DELETE"], - ) - - # Projects queries endoints (TODO: Refactor them into the REST endpoints) - api.add_resource(ProjectsQueriesBboxAPI, format_url("projects/queries/bbox/")) - api.add_resource( - ProjectsQueriesOwnerAPI, format_url("projects/queries/myself/owner/") - ) - api.add_resource( - ProjectsQueriesTouchedAPI, - format_url("projects/queries//touched/"), - ) - api.add_resource( - ProjectsQueriesSummaryAPI, - format_url("projects//queries/summary/"), - ) - api.add_resource( - ProjectsQueriesNoGeometriesAPI, - format_url("projects//queries/nogeometries/"), - ) - api.add_resource( - ProjectsQueriesNoTasksAPI, - format_url("projects//queries/notasks/"), - ) - api.add_resource( - ProjectsQueriesAoiAPI, format_url("projects//queries/aoi/") - ) - api.add_resource( - ProjectsQueriesPriorityAreasAPI, - format_url("projects//queries/priority-areas/"), - ) - api.add_resource( - ProjectsQueriesFeaturedAPI, format_url("projects/queries/featured/") - ) - api.add_resource( - ProjectQueriesSimilarProjectsAPI, - format_url("projects/queries//similar-projects/"), - ) - api.add_resource( - ProjectQueriesActiveProjectsAPI, - format_url("projects/queries/active/"), - ) - - # Projects' addtional resources - api.add_resource( - ProjectsActivitiesAPI, format_url("projects//activities/") - ) - api.add_resource( - ProjectsLastActivitiesAPI, - format_url("projects//activities/latest/"), - ) - api.add_resource( - ProjectsContributionsAPI, format_url("projects//contributions/") - ) - api.add_resource( - ProjectsContributionsQueriesDayAPI, - format_url("projects//contributions/queries/day/"), - ) - api.add_resource( - ProjectsStatisticsAPI, format_url("projects//statistics/") - ) - - api.add_resource( - ProjectsStatisticsQueriesUsernameAPI, - format_url("projects//statistics/queries//"), - ) - - api.add_resource( - ProjectsStatisticsQueriesPopularAPI, format_url("projects/queries/popular/") - ) - - api.add_resource( - ProjectPartnershipsRestApi, - format_url("projects/partnerships//"), - methods=["GET", "PATCH", "DELETE"], - ) - - api.add_resource( - ProjectPartnershipsRestApi, - format_url("projects/partnerships/"), - endpoint="create_partnership", - methods=["POST"], - ) - - api.add_resource( - PartnersByProjectAPI, - format_url("/projects//partners"), - methods=["GET"], - ) - - api.add_resource( - ProjectsTeamsAPI, - format_url("projects//teams/"), - endpoint="get_all_project_teams", - methods=["GET"], - ) - api.add_resource( - ProjectsTeamsAPI, - format_url("projects//teams//"), - methods=["POST", "DELETE", "PATCH"], - ) - api.add_resource( - ProjectsCampaignsAPI, - format_url("projects//campaigns/"), - endpoint="get_all_project_campaigns", - methods=["GET"], - ) - api.add_resource( - ProjectsCampaignsAPI, - format_url("projects//campaigns//"), - endpoint="assign_remove_campaign_to_project", - methods=["POST", "DELETE"], - ) - - # Projects actions endoints - api.add_resource( - ProjectsActionsMessageContributorsAPI, - format_url("projects//actions/message-contributors/"), - ) - api.add_resource( - ProjectsActionsTransferAPI, - format_url("projects//actions/transfer-ownership/"), - ) - api.add_resource( - ProjectsActionsFeatureAPI, - format_url("projects//actions/feature/"), - ) - api.add_resource( - ProjectsActionsUnFeatureAPI, - format_url("projects//actions/remove-feature/"), - methods=["POST"], - ) - - api.add_resource( - ProjectsFavoritesAPI, - format_url("projects//favorite/"), - methods=["GET", "POST", "DELETE"], - ) - - api.add_resource( - ProjectsActionsSetInterestsAPI, - format_url("projects//actions/set-interests/"), - methods=["POST"], - ) - - api.add_resource( - ProjectActionsIntersectingTilesAPI, - format_url("projects/actions/intersecting-tiles/"), - methods=["POST"], - ) - - api.add_resource( - UsersActionsSetInterestsAPI, - format_url("users/me/actions/set-interests/"), - endpoint="create_user_interest", - methods=["POST"], - ) - - api.add_resource( - UsersStatisticsInterestsAPI, - format_url("users//statistics/interests/"), - methods=["GET"], - ) - - api.add_resource( - InterestsAllAPI, - format_url("interests/"), - endpoint="create_interest", - methods=["POST", "GET"], - ) - api.add_resource( - InterestsRestAPI, - format_url("interests//"), - methods=["GET", "PATCH", "DELETE"], - ) - - # Partners REST endoints - api.add_resource( - PartnersAllRestAPI, - format_url("partners/"), - methods=["GET", "POST"], - ) - api.add_resource( - PartnerRestAPI, - format_url("partners//"), - methods=["GET", "DELETE", "PUT"], - ) - api.add_resource( - GroupPartnerStatisticsAPI, - format_url("/partners//general-statistics"), - methods=["GET"], - ) - api.add_resource( - FilteredPartnerStatisticsAPI, - format_url("/partners//filtered-statistics"), - methods=["GET"], - ) - api.add_resource( - PartnerPermalinkRestAPI, - format_url("partners//"), - methods=["GET"], - ) - - # Tasks REST endpoint - api.add_resource( - TasksRestAPI, format_url("projects//tasks//") - ) - - # Tasks queries endoints (TODO: Refactor them into the REST endpoints) - api.add_resource( - TasksQueriesJsonAPI, - format_url("projects//tasks/"), - methods=["GET", "DELETE"], - ) - api.add_resource( - TasksQueriesXmlAPI, format_url("projects//tasks/queries/xml/") - ) - api.add_resource( - TasksQueriesGpxAPI, format_url("projects//tasks/queries/gpx/") - ) - api.add_resource( - TasksQueriesAoiAPI, format_url("projects//tasks/queries/aoi/") - ) - api.add_resource( - TasksQueriesMappedAPI, - format_url("projects//tasks/queries/mapped/"), - ) - api.add_resource( - TasksQueriesOwnInvalidatedAPI, - format_url("projects//tasks/queries/own/invalidated/"), - ) - - # Tasks actions endoints - api.add_resource( - TasksActionsMappingLockAPI, - format_url( - "projects//tasks/actions/lock-for-mapping//" - ), - ) - api.add_resource( - TasksActionsMappingStopAPI, - format_url( - "projects//tasks/actions/stop-mapping//" - ), - ) - api.add_resource( - TasksActionsMappingUnlockAPI, - format_url( - "projects//tasks/actions/unlock-after-mapping//" - ), - ) - api.add_resource( - TasksActionsMappingUndoAPI, - format_url( - "projects//tasks/actions/undo-last-action//" - ), - ) - api.add_resource( - TasksActionsExtendAPI, - format_url("projects//tasks/actions/extend/"), - ) - api.add_resource( - TasksActionsValidationLockAPI, - format_url("projects//tasks/actions/lock-for-validation/"), - ) - api.add_resource( - TasksActionsValidationStopAPI, - format_url("projects//tasks/actions/stop-validation/"), - ) - api.add_resource( - TasksActionsValidationUnlockAPI, - format_url("projects//tasks/actions/unlock-after-validation/"), - ) - api.add_resource( - TasksActionsMapAllAPI, - format_url("projects//tasks/actions/map-all/"), - ) - api.add_resource( - TasksActionsValidateAllAPI, - format_url("projects//tasks/actions/validate-all/"), - ) - api.add_resource( - TasksActionsInvalidateAllAPI, - format_url("projects//tasks/actions/invalidate-all/"), - ) - api.add_resource( - TasksActionsResetBadImageryAllAPI, - format_url("projects//tasks/actions/reset-all-badimagery/"), - ) - api.add_resource( - TasksActionsResetAllAPI, - format_url("projects//tasks/actions/reset-all/"), - ) - api.add_resource( - TasksActionsReverUserTaskstAPI, - format_url("projects//tasks/actions/reset-by-user/"), - ) - api.add_resource( - TasksActionsSplitAPI, - format_url("projects//tasks/actions/split//"), - ) - - # Tasks Statistics endpoint - api.add_resource( - TasksStatisticsAPI, - format_url("tasks/statistics/"), - methods=["GET"], - ) - - # Comments REST endoints - api.add_resource( - CommentsProjectsAllAPI, - format_url("projects//comments/"), - methods=["GET", "POST"], - ) - api.add_resource( - CommentsProjectsRestAPI, - format_url("projects//comments//"), - methods=["DELETE"], - ) - api.add_resource( - CommentsTasksRestAPI, - format_url("projects//comments/tasks//"), - methods=["GET", "POST"], - ) - - # Annotations REST endoints - api.add_resource( - AnnotationsRestAPI, - format_url("projects//annotations//"), - format_url("projects//annotations/"), - methods=["GET", "POST"], - ) - - # Issues REST endpoints - api.add_resource( - IssuesAllAPI, format_url("tasks/issues/categories/"), methods=["GET", "POST"] - ) - api.add_resource( - IssuesRestAPI, - format_url("tasks/issues/categories//"), - methods=["GET", "PATCH", "DELETE"], - ) - - # Licenses REST endpoints - api.add_resource(LicensesAllAPI, format_url("licenses/")) - api.add_resource( - LicensesRestAPI, - format_url("licenses/"), - endpoint="create_license", - methods=["POST"], - ) - api.add_resource( - LicensesRestAPI, - format_url("licenses//"), - methods=["GET", "PATCH", "DELETE"], - ) - - # Licenses actions endpoint - api.add_resource( - LicensesActionsAcceptAPI, - format_url("licenses//actions/accept-for-me/"), - ) - - # Countries REST endpoints - api.add_resource(CountriesRestAPI, format_url("countries/")) - - # Organisations REST endpoints - api.add_resource(OrganisationsAllAPI, format_url("organisations/")) - api.add_resource( - OrganisationsRestAPI, - format_url("organisations/"), - endpoint="create_organisation", - methods=["POST"], - ) - api.add_resource( - OrganisationsRestAPI, - format_url("organisations//"), - endpoint="get_organisation", - methods=["GET"], - ) - api.add_resource( - OrganisationsBySlugRestAPI, - format_url("organisations//"), - endpoint="get_organisation_by_slug", - methods=["GET"], - ) - api.add_resource( - OrganisationsRestAPI, - format_url("organisations//"), - methods=["PUT", "DELETE", "PATCH"], - ) - - # Organisations additional resources endpoints - api.add_resource( - OrganisationsStatsAPI, - format_url("organisations//statistics/"), - endpoint="get_organisation_stats", - methods=["GET"], - ) - api.add_resource( - OrganisationsCampaignsAPI, - format_url("organisations//campaigns/"), - endpoint="get_all_organisation_campaigns", - methods=["GET"], - ) - api.add_resource( - OrganisationsCampaignsAPI, - format_url("organisations//campaigns//"), - endpoint="assign_campaign_to_organisation", - methods=["POST", "DELETE"], - ) - - # Teams REST endpoints - api.add_resource(TeamsAllAPI, format_url("teams"), methods=["GET"]) - api.add_resource( - TeamsAllAPI, format_url("teams/"), endpoint="create_team", methods=["POST"] - ) - api.add_resource( - TeamsRestAPI, - format_url("teams//"), - methods=["GET", "DELETE", "PATCH"], - ) - api.add_resource( - TeamsJoinRequestAPI, format_url("teams/join_requests/"), methods=["GET"] - ) - - # Teams actions endpoints - api.add_resource( - TeamsActionsJoinAPI, - format_url("teams//actions/join/"), - methods=["POST", "PATCH"], - ) - api.add_resource( - TeamsActionsAddAPI, - format_url("teams//actions/add/"), - methods=["POST"], - ) - api.add_resource( - TeamsActionsLeaveAPI, - format_url("teams//actions/leave/"), - endpoint="leave_team", - methods=["POST"], - ) - api.add_resource( - TeamsActionsMessageMembersAPI, - format_url("teams//actions/message-members/"), - ) - - # Campaigns REST endpoints - api.add_resource( - CampaignsAllAPI, - format_url("campaigns/"), - endpoint="get_all_campaign", - methods=["GET"], - ) - api.add_resource( - CampaignsAllAPI, - format_url("campaigns/"), - endpoint="create_campaign", - methods=["POST"], - ) - api.add_resource( - CampaignsRestAPI, - format_url("campaigns//"), - methods=["GET", "PATCH", "DELETE"], - ) - - # Notifications REST endpoints - api.add_resource( - NotificationsRestAPI, format_url("notifications//") - ) - api.add_resource(NotificationsAllAPI, format_url("notifications/")) - api.add_resource( - NotificationsQueriesCountUnreadAPI, - format_url("notifications/queries/own/count-unread/"), - ) - api.add_resource( - NotificationsQueriesPostUnreadAPI, - format_url("notifications/queries/own/post-unread/"), - methods=["POST"], - ) - # Notifications Actions endpoints - api.add_resource( - NotificationsActionsDeleteMultipleAPI, - format_url("notifications/delete-multiple/"), - methods=["DELETE"], - ) - api.add_resource( - NotificationsActionsDeleteAllAPI, - format_url("notifications/delete-all/"), - methods=["DELETE"], - ) - api.add_resource( - NotificationsActionsMarkAsReadAllAPI, - format_url("notifications/mark-as-read-all/"), - methods=["POST"], - ) - api.add_resource( - NotificationsActionsMarkAsReadMultipleAPI, - format_url("notifications/mark-as-read-multiple/"), - methods=["POST"], - ) - - # Users REST endpoint - api.add_resource(UsersAllAPI, format_url("users/")) - api.add_resource(UsersRestAPI, format_url("users//")) - api.add_resource( - UsersQueriesUsernameFilterAPI, - format_url("users/queries/filter//"), - ) - api.add_resource( - UsersQueriesUsernameAPI, format_url("users/queries//") - ) - api.add_resource(UsersQueriesFavoritesAPI, format_url("users/queries/favorites/")) - api.add_resource( - UsersQueriesOwnLockedAPI, format_url("users/queries/tasks/locked/") - ) - api.add_resource( - UsersQueriesOwnLockedDetailsAPI, - format_url("users/queries/tasks/locked/details/"), - ) - - # Users Actions endpoint - api.add_resource(UsersActionsSetUsersAPI, format_url("users/me/actions/set-user/")) - - api.add_resource( - UsersActionsSetLevelAPI, - format_url("users//actions/set-level//"), - ) - api.add_resource( - UsersActionsSetRoleAPI, - format_url("users//actions/set-role//"), - ) - api.add_resource( - UsersActionsSetExpertModeAPI, - format_url( - "users//actions/set-expert-mode//" - ), - ) - - api.add_resource(UsersTasksAPI, format_url("users//tasks/")) - api.add_resource( - UsersActionsVerifyEmailAPI, format_url("users/me/actions/verify-email/") - ) - api.add_resource( - UsersActionsRegisterEmailAPI, format_url("users/actions/register/") - ) - - # Users Statistics endpoint - api.add_resource( - UsersStatisticsAPI, format_url("users//statistics/") - ) - - api.add_resource( - UsersStatisticsAllAPI, - format_url("users/statistics/"), - ) - api.add_resource( - OhsomeProxyAPI, format_url("users/statistics/ohsome/"), methods=["GET"] - ) - # User RecommendedProjects endpoint - api.add_resource( - UsersRecommendedProjectsAPI, - format_url("users//recommended-projects/"), - ) - - # User Interests endpoint - api.add_resource( - UsersQueriesInterestsAPI, - format_url("users//queries/interests/"), - ) - - # Users openstreetmap endpoint - api.add_resource( - UsersOpenStreetMapAPI, format_url("users//openstreetmap/") - ) - - # System endpoint - api.add_resource(SystemDocsAPI, format_url("system/docs/json/")) - api.add_resource( - SystemBannerAPI, format_url("system/banner/"), methods=["GET", "PATCH"] - ) - api.add_resource(SystemHeartbeatAPI, format_url("system/heartbeat/")) - api.add_resource(SystemLanguagesAPI, format_url("system/languages/")) - api.add_resource(SystemStatisticsAPI, format_url("system/statistics/")) - api.add_resource( - SystemAuthenticationLoginAPI, format_url("system/authentication/login/") - ) - api.add_resource( - SystemAuthenticationCallbackAPI, format_url("system/authentication/callback/") - ) - api.add_resource( - SystemAuthenticationEmailAPI, format_url("system/authentication/email/") - ) - api.add_resource( - SystemImageUploadRestAPI, - format_url("system/image-upload/"), - methods=["POST"], - ) - api.add_resource( - SystemApplicationsRestAPI, - format_url("system/authentication/applications/"), - methods=["POST", "GET"], - ) - api.add_resource( - SystemApplicationsRestAPI, - format_url("system/authentication/applications//"), - endpoint="delete_application", - methods=["DELETE"], - ) - api.add_resource( - SystemApplicationsRestAPI, - format_url("system/authentication/applications//"), - endpoint="check_application", - methods=["PATCH"], - ) - api.add_resource( - SystemContactAdminRestAPI, format_url("system/contact-admin/"), methods=["POST"] - ) - api.add_resource(SystemReleaseAPI, format_url("system/release/"), methods=["POST"]) diff --git a/backend/api/annotations/resources.py b/backend/api/annotations/resources.py index 9505b066c6..5e3ad912c2 100644 --- a/backend/api/annotations/resources.py +++ b/backend/api/annotations/resources.py @@ -1,153 +1,168 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from loguru import logger + +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.task import Task from backend.models.postgis.task_annotation import TaskAnnotation from backend.services.project_service import ProjectService from backend.services.task_annotations_service import TaskAnnotationsService -from backend.services.application_service import ApplicationService +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class AnnotationsRestAPI(Resource): - def get(self, project_id: int, annotation_type: str = None): - """ - Get all task annotations for a project - --- - tags: - - annotations - produces: - - application/json - parameters: - - name: project_id - in: path - description: The ID of the project - required: true - type: integer - - name: annotation_type - in: path - description: The type of annotation to fetch - required: false - type: integer - responses: - 200: - description: Project Annotations - 404: - description: Project or annotations not found - 500: - description: Internal Server Error - """ - ProjectService.exists(project_id) - if annotation_type: - annotations = TaskAnnotation.get_task_annotations_by_project_id_type( - project_id, annotation_type - ) - else: - annotations = TaskAnnotation.get_task_annotations_by_project_id(project_id) - return annotations.to_primitive(), 200 - def post(self, project_id: int, annotation_type: str): - """ - Store new task annotations for tasks of a project - --- - tags: - - annotations - produces: - - application/json - parameters: - - in: header - name: Content-Type - description: Content type for post body - required: true - type: string - default: application/json - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - - name: annotation_type - in: path - description: Annotation type - required: true - type: string - - name: Application-Token - in: header - description: Application token registered with TM - required: true - type: string - - in: body - name: body - required: true - description: JSON object for creating draft project - schema: - projectId: - type: integer - required: true - annotationType: - type: string - required: true - tasks: - type: array - required: true - items: - schema: - taskId: - type: integer - required: true - annotationSource: - type: string - annotationMarkdown: - type: string - properties: - description: JSON object with properties - responses: - 200: - description: Project updated - 400: - description: Client Error - Invalid Request - 404: - description: Project or task not found - 500: - description: Internal Server Error - """ +@router.get("/{project_id}/annotations/{annotation_type}/") +@router.get("/{project_id}/annotations/") +async def get_annotations( + request: Request, + project_id: int, + annotation_type: str = None, + db: Database = Depends(get_db), +): + """ + Get all task annotations for a project + --- + tags: + - annotations + produces: + - application/json + parameters: + - name: project_id + in: path + description: The ID of the project + required: true + type: integer + - name: annotation_type + in: path + description: The type of annotation to fetch + required: false + type: integer + responses: + 200: + description: Project Annotations + 404: + description: Project or annotations not found + 500: + description: Internal Server Error + """ + await ProjectService.exists(project_id, db) + if annotation_type: + annotations = await TaskAnnotation.get_task_annotations_by_project_id_type( + project_id, annotation_type, db + ) + else: + annotations = await TaskAnnotation.get_task_annotations_by_project_id( + project_id, db + ) + return annotations.model_dump(by_alias=True) - if "Application-Token" in request.headers: - application_token = request.headers["Application-Token"] - is_valid_token = ApplicationService.check_token(application_token) # noqa - else: - current_app.logger.error("No token supplied") - return {"Error": "No token supplied", "SubCode": "NotFound"}, 500 - try: - annotations = request.get_json() or {} - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") +@router.post("/{project_id}/annotations/{annotation_type}/") +async def post_annotations( + request: Request, + project_id: int, + annotation_type: str, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Store new task annotations for tasks of a project + --- + tags: + - annotations + produces: + - application/json + parameters: + - in: header + name: Content-Type + description: Content type for post body + required: true + type: string + default: application/json + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + - name: annotation_type + in: path + description: Annotation type + required: true + type: string + - name: Application-Token + in: header + description: Application token registered with TM + required: true + type: string + - in: body + name: body + required: true + description: JSON object for creating draft project + schema: + projectId: + type: integer + required: true + annotationType: + type: string + required: true + tasks: + type: array + required: true + items: + schema: + taskId: + type: integer + required: true + annotationSource: + type: string + annotationMarkdown: + type: string + properties: + description: JSON object with properties + responses: + 200: + description: Project updated + 400: + description: Client Error - Invalid Request + 404: + description: Project or task not found + 500: + description: Internal Server Error + """ + try: + annotations = await request.json() or {} + except Exception as e: + logger.error(f"Error validating request: {str(e)}") - ProjectService.exists(project_id) + await ProjectService.exists(project_id, db) - task_ids = [t["taskId"] for t in annotations["tasks"]] + task_ids = [t["taskId"] for t in annotations["tasks"]] - # check if task ids are valid - tasks = Task.get_tasks(project_id, task_ids) - tasks_ids_db = [t.id for t in tasks] - if len(task_ids) != len(tasks_ids_db): - return {"Error": "Invalid task id"}, 500 + tasks = await Task.get_tasks(project_id, task_ids, db) + tasks_ids_db = [t.id for t in tasks] + if len(task_ids) != len(tasks_ids_db): + return JSONResponse(content={"Error": "Invalid task id"}, status_code=500) - for annotation in annotations["tasks"]: - try: - TaskAnnotationsService.add_or_update_annotation( - annotation, project_id, annotation_type - ) - except DataError as e: - current_app.logger.error(f"Error creating annotations: {str(e)}") - return { + for annotation in annotations["tasks"]: + try: + await TaskAnnotationsService.add_or_update_annotation( + annotation, project_id, annotation_type, db + ) + except Exception as e: + logger.error(f"Error creating annotations: {str(e)}") + return JSONResponse( + content={ "Error": "Error creating annotations", "SubCode": "InvalidData", - }, 400 - - return project_id, 200 + }, + status_code=400, + ) - def put(self, project_id: int, task_id: int): - """ - Update a single task's annotations - """ - pass + return project_id diff --git a/backend/api/campaigns/resources.py b/backend/api/campaigns/resources.py index 466090ce00..498bcc5d07 100644 --- a/backend/api/campaigns/resources.py +++ b/backend/api/campaigns/resources.py @@ -1,292 +1,322 @@ -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse -from backend.models.dtos.campaign_dto import CampaignDTO, NewCampaignDTO +from backend.db import get_db +from backend.models.dtos.campaign_dto import ( + CampaignDTO, + CampaignListDTO, + NewCampaignDTO, +) +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.campaign_service import CampaignService from backend.services.organisation_service import OrganisationService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/campaigns", + tags=["campaigns"], + responses={404: {"description": "Not found"}}, +) -class CampaignsRestAPI(Resource): - def get(self, campaign_id): - """ - Get an active campaign's information - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: campaign_id - in: path - description: Campaign ID - required: true - type: integer - default: 1 - responses: - 200: - description: Campaign found - 404: - description: No Campaign found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if authenticated_user_id: - campaign = CampaignService.get_campaign_as_dto( - campaign_id, authenticated_user_id - ) - else: - campaign = CampaignService.get_campaign_as_dto(campaign_id, 0) - return campaign.to_primitive(), 200 - @token_auth.login_required - def patch(self, campaign_id): - """ - Updates an existing campaign - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: campaign_id - in: path - description: Campaign ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating a Campaign - schema: - properties: - name: - type: string - example: HOT Campaign - logo: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - url: - type: string - example: https://hotosm.org - organisations: - type: array - items: - type: integer - default: [ - 1 - ] - responses: - 200: - description: Campaign updated successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Campaign not found - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"CampaignsRestAPI PATCH: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 +@router.get("/{campaign_id}/", response_model=CampaignDTO) +async def retrieve_campaign( + request: Request, campaign_id: int, db: Database = Depends(get_db) +): + """ + Get an active campaign's information + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: campaign_id + in: path + description: Campaign ID + required: true + type: integer + default: 1 + responses: + 200: + description: Campaign found + 404: + description: No Campaign found + 500: + description: Internal Server Error + """ + campaign = await CampaignService.get_campaign_as_dto(campaign_id, db) + return campaign - try: - campaign_dto = CampaignDTO(request.get_json()) - campaign_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - try: - campaign = CampaignService.update_campaign(campaign_dto, campaign_id) - return {"Success": "Campaign {} updated".format(campaign.id)}, 200 - except ValueError: - error_msg = "Campaign PATCH - name already exists" - return {"Error": error_msg, "SubCode": "NameExists"}, 409 +@router.patch("/{campaign_id}/") +async def update_campaign( + campaign_dto: CampaignDTO, + request: Request, + campaign_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Updates an existing campaign + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: campaign_id + in: path + description: Campaign ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating a Campaign + schema: + properties: + name: + type: string + example: HOT Campaign + logo: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + url: + type: string + example: https://hotosm.org + organisations: + type: array + items: + type: integer + default: [ + 1 + ] + responses: + 200: + description: Campaign updated successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Campaign not found + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + user.id, db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"CampaignsRestAPI PATCH: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) + try: + campaign = await CampaignService.update_campaign(campaign_dto, campaign_id, db) + return JSONResponse( + content={"Success": "Campaign {} updated".format(campaign.id)}, + status_code=200, + ) + except ValueError: + error_msg = "Campaign PATCH - name already exists" + return JSONResponse( + content={"Error": error_msg, "SubCode": "NameExists"}, status_code=400 + ) - @token_auth.login_required - def delete(self, campaign_id): - """ - Deletes an existing campaign - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: campaign_id - in: path - description: Campaign ID - required: true - type: integer - default: 1 - responses: - 200: - description: Campaign deleted successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Campaign not found - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"CampaignsRestAPI DELETE: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 - campaign = CampaignService.get_campaign(campaign_id) - CampaignService.delete_campaign(campaign.id) - return {"Success": "Campaign deleted"}, 200 +@router.delete("/{campaign_id}/") +async def delete_campaign( + request: Request, + campaign_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Deletes an existing campaign + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: campaign_id + in: path + description: Campaign ID + required: true + type: integer + default: 1 + responses: + 200: + description: Campaign deleted successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Campaign not found + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + request.user.display_name, db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"CampaignsRestAPI DELETE: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) + campaign = await CampaignService.get_campaign(campaign_id, db) + await CampaignService.delete_campaign(campaign.id, db) + return JSONResponse(content={"Success": "Campaign deleted"}, status_code=200) -class CampaignsAllAPI(Resource): - def get(self): - """ - Get all active campaigns - --- - tags: - - campaigns - produces: - - application/json - responses: - 200: - description: All Campaigns returned successfully - 500: - description: Internal Server Error - """ - campaigns = CampaignService.get_all_campaigns() - return campaigns.to_primitive(), 200 - @token_auth.login_required - def post(self): - """ - Creates a new campaign - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - in: body - name: body - required: true - description: JSON object for creating a new Campaign - schema: - properties: - name: - type: string - example: HOT Campaign - logo: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - url: - type: string - example: https://hotosm.org - organisations: - type: array - items: - type: integer - default: [ - 1 - ] - responses: - 201: - description: New campaign created successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"CampaignsAllAPI POST: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 +@router.get("/", response_model=CampaignListDTO) +async def list_campaigns( + request: Request, + db: Database = Depends(get_db), +): + """ + Get all active campaigns + --- + tags: + - campaigns + produces: + - application/json + responses: + 200: + description: All Campaigns returned successfully + 500: + description: Internal Server Error + """ + campaigns = await CampaignService.get_all_campaigns(db) + return campaigns - try: - campaign_dto = NewCampaignDTO(request.get_json()) - campaign_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - try: - campaign = CampaignService.create_campaign(campaign_dto) - return {"campaignId": campaign.id}, 201 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 409 +@router.post("/") +async def create_campaign( + campaign_dto: NewCampaignDTO, + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Creates a new campaign + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - in: body + name: body + required: true + description: JSON object for creating a new Campaign + schema: + properties: + name: + type: string + example: HOT Campaign + logo: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + url: + type: string + example: https://hotosm.org + organisations: + type: array + items: + type: integer + default: [ + 1 + ] + responses: + 201: + description: New campaign created successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + request.user.display_name, db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"CampaignsAllAPI POST: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) + + try: + campaign_id = await CampaignService.create_campaign(campaign_dto, db) + return JSONResponse(content={"campaignId": campaign_id}, status_code=201) + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=409, + ) diff --git a/backend/api/comments/resources.py b/backend/api/comments/resources.py index 0e031360ef..11c0d0c550 100644 --- a/backend/api/comments/resources.py +++ b/backend/api/comments/resources.py @@ -1,311 +1,358 @@ -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, BackgroundTasks, Depends, Request, Query +from fastapi.responses import JSONResponse +from loguru import logger -from backend.models.dtos.message_dto import ChatMessageDTO +from backend.db import get_db from backend.models.dtos.mapping_dto import TaskCommentDTO +from backend.models.dtos.message_dto import ChatMessageDTO +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.utils import timestamp +from backend.services.mapping_service import MappingService, MappingServiceError from backend.services.messaging.chat_service import ChatService -from backend.services.users.user_service import UserService from backend.services.project_service import ProjectService -from backend.services.mapping_service import MappingService, MappingServiceError -from backend.services.users.authentication_service import token_auth, tm - +from backend.services.users.authentication_service import login_required +from backend.services.users.user_service import UserService -class CommentsProjectsAllAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def post(self, project_id): - """ - Add a message to project chat - --- - tags: - - comments - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID to attach the chat message to - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating a new mapping license - schema: - properties: - message: - type: string - default: This is an awesome project - responses: - 201: - description: Message posted successfully - 400: - description: Invalid Request - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if UserService.is_user_blocked(authenticated_user_id): - return {"Error": "User is on read only mode", "SubCode": "ReadOnly"}, 403 +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) - try: - chat_dto = ChatMessageDTO(request.get_json()) - chat_dto.user_id = authenticated_user_id - chat_dto.project_id = project_id - chat_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { - "Error": "Unable to add chat message", - "SubCode": "InvalidData", - }, 400 - try: - project_messages = ChatService.post_message( - chat_dto, project_id, authenticated_user_id +@router.post("/{project_id}/comments/") +async def post_comment( + project_id: int, + request: Request, + background_tasks: BackgroundTasks, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Add a message to project chat + --- + tags: + - comments + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID to attach the chat message to + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating a new mapping license + schema: + properties: + message: + type: string + default: This is an awesome project + responses: + 201: + description: Message posted successfully + 400: + description: Invalid Request + 500: + description: Internal Server Error + """ + user = await UserService.get_user_by_id(user.id, db) + if await UserService.is_user_blocked(user.id, db): + return JSONResponse( + content={"Error": "User is on read only mode", "SubCode": "ReadOnly"}, + status_code=403, + ) + request_json = await request.json() + message = request_json.get("message") + chat_dto = ChatMessageDTO( + message=message, + user_id=user.id, + project_id=project_id, + timestamp=timestamp(), + username=user.username, + ) + try: + async with db.transaction(): + project_messages = await ChatService.post_message( + chat_dto, project_id, user.id, db, background_tasks ) - return project_messages.to_primitive(), 201 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + return project_messages + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) - def get(self, project_id): - """ - Get all chat messages for a project - --- - tags: - - comments - produces: - - application/json - parameters: - - name: project_id - in: path - description: Project ID to attach the chat message to - required: true - type: integer - default: 1 - - in: query - name: page - description: Page of results user requested - type: integer - default: 1 - - in: query - name: perPage - description: Number of elements per page. - type: integer - default: 20 - responses: - 200: - description: All messages - 404: - description: No chat messages on project - 500: - description: Internal Server Error - """ - ProjectService.exists(project_id) - page = int(request.args.get("page")) if request.args.get("page") else 1 - per_page = int(request.args.get("perPage", 20)) - project_messages = ChatService.get_messages(project_id, page, per_page) - return project_messages.to_primitive(), 200 +@router.get("/{project_id}/comments/") +async def get_comments( + project_id: int, + page: int = Query(1, description="Page of results user requested"), + per_page: int = Query( + 20, alias="perPage", description="Number of elements per page" + ), + db: Database = Depends(get_db), +): + """ + Get all chat messages for a project + --- + tags: + - comments + produces: + - application/json + parameters: + - name: project_id + in: path + description: Project ID to attach the chat message to + required: true + type: integer + default: 1 + - in: query + name: page + description: Page of results user requested + type: integer + default: 1 + - in: query + name: perPage + description: Number of elements per page. + type: integer + default: 20 + responses: + 200: + description: All messages + 404: + description: No chat messages on project + 500: + description: Internal Server Error + """ + await ProjectService.exists(project_id, db) + project_messages = await ChatService.get_messages(project_id, db, page, per_page) + return project_messages -class CommentsProjectsRestAPI(Resource): - @token_auth.login_required - def delete(self, project_id, comment_id): - """ - Delete a chat message - --- - tags: - - comments - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID to attach the chat message to - required: true - type: integer - default: 1 - - name: comment_id - in: path - description: Comment ID to delete - required: true - type: integer - default: 1 - responses: - 200: - description: Comment deleted - 403: - description: User is not authorized to delete comment - 404: - description: Comment not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - try: - ChatService.delete_project_chat_by_id( - project_id, comment_id, authenticated_user_id + +@router.delete("/{project_id}/comments/{comment_id}/") +async def delete_comment( + project_id: int, + comment_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Delete a chat message + --- + tags: + - comments + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID to attach the chat message to + required: true + type: integer + default: 1 + - name: comment_id + in: path + description: Comment ID to delete + required: true + type: integer + default: 1 + responses: + 200: + description: Comment deleted + 403: + description: User is not authorized to delete comment + 404: + description: Comment not found + 500: + description: Internal Server Error + """ + authenticated_user_id = user.id + try: + async with db.transaction(): + await ChatService.delete_project_chat_by_id( + project_id, comment_id, authenticated_user_id, db ) - return {"Success": "Comment deleted"}, 200 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + return JSONResponse(content={"Success": "Comment deleted"}, status_code=200) + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class CommentsTasksRestAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def post(self, project_id, task_id): - """ - Adds a comment to the task outside of mapping/validation - --- - tags: - - comments - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object representing the comment - schema: - id: TaskComment - required: - - comment - properties: - comment: - type: string - description: user comment about the task - responses: - 200: - description: Comment added - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if UserService.is_user_blocked(authenticated_user_id): - return {"Error": "User is on read only mode", "SubCode": "ReadOnly"}, 403 +@router.post("/{project_id}/comments/tasks/{task_id}/") +async def post_task_comment( + request: Request, + project_id: int, + task_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Adds a comment to the task outside of mapping/validation + --- + tags: + - comments + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object representing the comment + schema: + id: TaskComment + required: + - comment + properties: + comment: + type: string + description: user comment about the task + responses: + 200: + description: Comment added + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + authenticated_user_id = request.user.display_name + if await UserService.is_user_blocked(authenticated_user_id, db): + return JSONResponse( + content={"Error": "User is on read only mode", "SubCode": "ReadOnly"}, + status_code=403, + ) - try: - task_comment = TaskCommentDTO(request.get_json()) - task_comment.user_id = token_auth.current_user() - task_comment.task_id = task_id - task_comment.project_id = project_id - task_comment.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to add comment", "SubCode": "InvalidData"}, 400 + try: + request_json = await request.json() + comment = request_json.get("comment") + task_comment = TaskCommentDTO( + user_id=user.id, task_id=task_id, project_id=project_id, comment=comment + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to add comment", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + task = await MappingService.add_task_comment(task_comment, db) + return task + except MappingServiceError: + return JSONResponse(content={"Error": "Task update failed"}, status_code=403) - try: - task = MappingService.add_task_comment(task_comment) - return task.to_primitive(), 201 - except MappingServiceError: - return {"Error": "Task update failed"}, 403 - def get(self, project_id, task_id): - """ - Get comments for a task - --- - tags: - - comments - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object representing the comment - schema: - id: TaskComment - required: - - comment - properties: - comment: - type: string - description: user comment about the task - responses: - 200: - description: Comment retrieved - 400: - description: Client Error - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - task_comment = TaskCommentDTO(request.get_json()) - task_comment.user_id = token_auth.current_user() - task_comment.task_id = task_id - task_comment.project_id = project_id - task_comment.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { +@router.get("/{project_id}/comments/tasks/{task_id}/") +async def get_task_comments(request: Request, project_id, task_id): + """ + Get comments for a task + --- + tags: + - comments + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object representing the comment + schema: + id: TaskComment + required: + - comment + properties: + comment: + type: string + description: user comment about the task + responses: + 200: + description: Comment retrieved + 400: + description: Client Error + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + task_comment = TaskCommentDTO(request.json()) + task_comment.user_id = request.user.display_name + task_comment.task_id = task_id + task_comment.project_id = project_id + task_comment.validate() + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to fetch task comments", "SubCode": "InvalidData", - }, 400 + }, + status_code=400, + ) - try: - # NEW FUNCTION HAS TO BE ADDED - # task = MappingService.add_task_comment(task_comment) - # return task.to_primitive(), 200 - return - except MappingServiceError as e: - return {"Error": str(e)}, 403 + try: + # NEW FUNCTION HAS TO BE ADDED + # task = MappingService.add_task_comment(task_comment) + # return task.model_dump(by_alias=True), 200 + return + except MappingServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=403) diff --git a/backend/api/countries/resources.py b/backend/api/countries/resources.py index 6389343e99..ba453a785b 100644 --- a/backend/api/countries/resources.py +++ b/backend/api/countries/resources.py @@ -1,21 +1,31 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends + +from backend.db import get_db +from backend.models.dtos.tags_dto import TagsDTO from backend.services.tags_service import TagsService +router = APIRouter( + prefix="/countries", + tags=["countries"], + responses={404: {"description": "Not found"}}, +) + -class CountriesRestAPI(Resource): - def get(self): - """ - Fetch all Country tags - --- - tags: - - countries - produces: - - application/json - responses: - 200: - description: All Country tags returned - 500: - description: Internal Server Error - """ - tags = TagsService.get_all_countries() - return tags.to_primitive(), 200 +@router.get("/", response_model=TagsDTO) +async def get_all_countries(db: Database = Depends(get_db)): + """ + Fetch all Country tags + --- + tags: + - countries + produces: + - application/json + responses: + 200: + description: All Country tags returned + 500: + description: Internal Server Error + """ + tags = await TagsService.get_all_countries(db) + return tags diff --git a/backend/api/interests/resources.py b/backend/api/interests/resources.py index d1c0a0e1c6..b73b9e2512 100644 --- a/backend/api/interests/resources.py +++ b/backend/api/interests/resources.py @@ -1,259 +1,281 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from asyncpg.exceptions import UniqueViolationError +from databases import Database +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from backend.db import get_db from backend.models.dtos.interests_dto import InterestDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.interests_service import InterestService from backend.services.organisation_service import OrganisationService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required -from sqlalchemy.exc import IntegrityError +router = APIRouter( + prefix="/interests", + tags=["interests"], + responses={404: {"description": "Not found"}}, +) INTEREST_NOT_FOUND = "Interest Not Found" -class InterestsAllAPI(Resource): - @token_auth.login_required - def post(self): - """ - Creates a new interest - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating a new interest - schema: - properties: - name: - type: string - default: Public Domain - responses: - 200: - description: New interest created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"InterestsAllAPI POST: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 +@router.post("/") +async def post_interest( + interest_dto: InterestDTO, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Creates a new interest + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a new interest + schema: + properties: + name: + type: string + default: Public Domain + responses: + 200: + description: New interest created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + user_id=user.id, db=db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"InterestsAllAPI POST: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) - try: - interest_dto = InterestDTO(request.get_json()) - interest_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 + try: + new_interest_dto = await InterestService.create(interest_dto.name, db) + return new_interest_dto - try: - new_interest = InterestService.create(interest_dto.name) - return new_interest.to_primitive(), 200 - except IntegrityError: - return ( - { - "Error": "Value '{0}' already exists".format(interest_dto.name), - "SubCode": "NameExists", - }, - 400, - ) + except UniqueViolationError: + return JSONResponse( + content={ + "Error": "Value '{0}' already exists".format(interest_dto.name), + "SubCode": "NameExists", + }, + status_code=400, + ) - def get(self): - """ - Get all interests - --- - tags: - - interests - produces: - - application/json - responses: - 200: - description: List of interests - 500: - description: Internal Server Error - """ - interests = InterestService.get_all_interests() - return interests.to_primitive(), 200 +@router.get("/") +async def get_interests(db: Database = Depends(get_db)): + """ + Get all interests + --- + tags: + - interests + produces: + - application/json + responses: + 200: + description: List of interests + 500: + description: Internal Server Error + """ + interests_dto = await InterestService.get_all_interests(db) + return interests_dto -class InterestsRestAPI(Resource): - @token_auth.login_required - def get(self, interest_id): - """ - Get an existing interest - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: interest_id - in: path - description: Interest ID - required: true - type: integer - default: 1 - responses: - 200: - description: Interest - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Interest not found - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"InterestsRestAPI GET: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 - interest = InterestService.get(interest_id) - return interest.to_primitive(), 200 +@router.get("/{interest_id}/") +async def retrieve_interest( + interest_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get an existing interest + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: interest_id + in: path + description: Interest ID + required: true + type: integer + default: 1 + responses: + 200: + description: Interest + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Interest not found + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + user_id=user.id, db=db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"InterestsRestAPI GET: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) - @token_auth.login_required - def patch(self, interest_id): - """ - Update an existing interest - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: interest_id - in: path - description: Interest ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating a new interest - schema: - properties: - name: - type: string - default: Public Domain - responses: - 200: - description: Interest updated - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Interest not found - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"InterestsRestAPI PATCH: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 + interest_dto = await InterestService.get(interest_id, db) + return interest_dto - try: - interest_dto = InterestDTO(request.get_json()) - interest_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - update_interest = InterestService.update(interest_id, interest_dto) - return update_interest.to_primitive(), 200 +@router.patch("/{interest_id}/") +async def patch_interest( + interest_id: int, + interest_dto: InterestDTO, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Update an existing interest + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: interest_id + in: path + description: Interest ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating a new interest + schema: + properties: + name: + type: string + default: Public Domain + responses: + 200: + description: Interest updated + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Interest not found + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + user_id=user.id, db=db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"InterestsRestAPI PATCH: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) - @token_auth.login_required - def delete(self, interest_id): - """ - Delete a specified interest - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: interest_id - in: path - description: Unique interest ID - required: true - type: integer - default: 1 - responses: - 200: - description: Interest deleted - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Interest not found - 500: - description: Internal Server Error - """ - try: - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - token_auth.current_user() - ) - if len(orgs_dto.organisations) < 1: - raise ValueError("User not a Org Manager") - except ValueError as e: - error_msg = f"InterestsRestAPI DELETE: {str(e)}" - return {"Error": error_msg, "SubCode": "UserNotPermitted"}, 403 + update_interest = await InterestService.update(interest_id, interest_dto, db) + return update_interest - InterestService.delete(interest_id) - return {"Success": "Interest deleted"}, 200 + +@router.delete("/{interest_id}/") +async def delete_interest( + interest_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Delete a specified interest + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: interest_id + in: path + description: Unique interest ID + required: true + type: integer + default: 1 + responses: + 200: + description: Interest deleted + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Interest not found + 500: + description: Internal Server Error + """ + try: + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + user_id=user.id, db=db + ) + if len(orgs_dto.organisations) < 1: + raise ValueError("User not a Org Manager") + except ValueError as e: + error_msg = f"InterestsRestAPI DELETE: {str(e)}" + return JSONResponse( + content={"Error": error_msg, "SubCode": "UserNotPermitted"}, status_code=403 + ) + + await InterestService.delete(interest_id, db) + return JSONResponse(content={"Success": "Interest deleted"}, status_code=200) diff --git a/backend/api/issues/resources.py b/backend/api/issues/resources.py index 3a60deadf6..737f4f84ed 100644 --- a/backend/api/issues/resources.py +++ b/backend/api/issues/resources.py @@ -1,219 +1,257 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Body, Depends, Request, Query +from fastapi.responses import JSONResponse +from loguru import logger +from backend.db import get_db from backend.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.mapping_issues_service import MappingIssueCategoryService -from backend.services.users.authentication_service import token_auth, tm +from backend.services.users.authentication_service import pm_only + +router = APIRouter( + prefix="/tasks", + tags=["issues"], + responses={404: {"description": "Not found"}}, +) ISSUE_NOT_FOUND = "Mapping-issue category not found" -class IssuesRestAPI(Resource): - def get(self, category_id): - """ - Get specified mapping-issue category - --- - tags: - - issues - produces: - - application/json - parameters: - - name: category_id - in: path - description: The unique mapping-issue category ID - required: true - type: integer - default: 1 - responses: - 200: - description: Mapping-issue category found - 404: - description: Mapping-issue category not found - 500: - description: Internal Server Error - """ - category_dto = MappingIssueCategoryService.get_mapping_issue_category_as_dto( - category_id - ) - return category_dto.to_primitive(), 200 +@router.get("/issues/categories/{category_id}/") +async def get_issue(category_id: int, db: Database = Depends(get_db)): + """ + Get specified mapping-issue category + --- + tags: + - issues + produces: + - application/json + parameters: + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + responses: + 200: + description: Mapping-issue category found + 404: + description: Mapping-issue category not found + 500: + description: Internal Server Error + """ + category_dto = await MappingIssueCategoryService.get_mapping_issue_category_as_dto( + category_id, db + ) + return category_dto.model_dump(by_alias=True) + - @tm.pm_only() - @token_auth.login_required - def patch(self, category_id): - """ - Update an existing mapping-issue category - --- - tags: - - issues - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: category_id - in: path - description: The unique mapping-issue category ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating a mapping-issue category - schema: - properties: - name: - type: string - description: - type: string - responses: - 200: - description: Mapping-issue category updated - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 404: - description: Mapping-issue category not found - 500: - description: Internal Server Error - """ - try: - category_dto = MappingIssueCategoryDTO(request.get_json()) - category_dto.category_id = category_id - category_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { +@router.patch("/issues/categories/{category_id}/") +async def patch_issue( + request: Request, + category_id: int, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), + data: MappingIssueCategoryDTO = Body(...), +): + """ + Update an existing mapping-issue category + --- + tags: + - issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating a mapping-issue category + schema: + properties: + name: + type: string + description: + type: string + responses: + 200: + description: Mapping-issue category updated + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 404: + description: Mapping-issue category not found + 500: + description: Internal Server Error + """ + try: + category_dto = data + category_dto.category_id = category_id + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to update mapping issue category", "SubCode": "InvalidData", - }, 400 - - updated_category = MappingIssueCategoryService.update_mapping_issue_category( - category_dto + }, + status_code=400, ) - return updated_category.to_primitive(), 200 - @tm.pm_only() - @token_auth.login_required - def delete(self, category_id): - """ - Delete the specified mapping-issue category. - Note that categories can be deleted only if they have never been associated with a task.\ - To instead archive a used category that is no longer needed, \ - update the category with its archived flag set to true. - --- - tags: - - issues - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: category_id - in: path - description: The unique mapping-issue category ID - required: true - type: integer - default: 1 - responses: - 200: - description: Mapping-issue category deleted - 401: - description: Unauthorized - Invalid credentials - 404: - description: Mapping-issue category not found - 500: - description: Internal Server Error - """ - MappingIssueCategoryService.delete_mapping_issue_category(category_id) - return {"Success": "Mapping-issue category deleted"}, 200 + updated_category = await MappingIssueCategoryService.update_mapping_issue_category( + category_dto, db + ) + return updated_category.model_dump(by_alias=True) -class IssuesAllAPI(Resource): - def get(self): - """ - Gets all mapping issue categories - --- - tags: - - issues - produces: - - application/json - parameters: - - in: query - name: includeArchived - description: Optional filter to include archived categories - type: boolean - default: false - responses: - 200: - description: Mapping issue categories - 500: - description: Internal Server Error - """ - include_archived = request.args.get("includeArchived") == "true" - categories = MappingIssueCategoryService.get_all_mapping_issue_categories( - include_archived - ) - return categories.to_primitive(), 200 +@router.delete("/issues/categories/{category_id}/") +async def delete_issue( + request: Request, + category_id: int, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), +): + """ + Delete the specified mapping-issue category. + Note that categories can be deleted only if they have never been associated with a task.\ + To instead archive a used category that is no longer needed, \ + update the category with its archived flag set to true. + --- + tags: + - issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: category_id + in: path + description: The unique mapping-issue category ID + required: true + type: integer + default: 1 + responses: + 200: + description: Mapping-issue category deleted + 401: + description: Unauthorized - Invalid credentials + 404: + description: Mapping-issue category not found + 500: + description: Internal Server Error + """ + await MappingIssueCategoryService.delete_mapping_issue_category(category_id, db) + return JSONResponse( + content={"Success": "Mapping-issue category deleted"}, status_code=200 + ) - @tm.pm_only() - @token_auth.login_required - def post(self): - """ - Creates a new mapping-issue category - --- - tags: - - issues - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating a new mapping-issue category - schema: - properties: - name: - type: string - required: true - description: - type: string - responses: - 200: - description: New mapping-issue category created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - category_dto = MappingIssueCategoryDTO(request.get_json()) - category_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { + +@router.get("/issues/categories/") +async def get_issues_categories( + include_archived: bool = Query( + False, + alias="includeArchived", + description="Optional filter to include archived categories", + ), + db: Database = Depends(get_db), +): + """ + Gets all mapping issue categories + --- + tags: + - issues + produces: + - application/json + parameters: + - in: query + name: includeArchived + description: Optional filter to include archived categories + type: boolean + default: false + responses: + 200: + description: Mapping issue categories + 500: + description: Internal Server Error + """ + categories = await MappingIssueCategoryService.get_all_mapping_issue_categories( + include_archived, db + ) + return categories.model_dump(by_alias=True) + + +@router.post("/issues/categories/", response_model=MappingIssueCategoryDTO) +async def post_issues_categories( + request: Request, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), + data: dict = Body(...), +): + """ + Creates a new mapping-issue category + --- + tags: + - issues + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a new mapping-issue category + schema: + properties: + name: + type: string + required: true + description: + type: string + responses: + 200: + description: New mapping-issue category created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + category_dto = MappingIssueCategoryDTO(**data) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to create a new mapping issue category", "SubCode": "InvalidData", - }, 400 - - new_category_id = MappingIssueCategoryService.create_mapping_issue_category( - category_dto + }, + status_code=400, ) - return {"categoryId": new_category_id}, 200 + + new_category_id = await MappingIssueCategoryService.create_mapping_issue_category( + category_dto, db + ) + return JSONResponse(content={"categoryId": new_category_id}, status_code=200) diff --git a/backend/api/licenses/actions.py b/backend/api/licenses/actions.py index 86ec6f8e20..100784f563 100644 --- a/backend/api/licenses/actions.py +++ b/backend/api/licenses/actions.py @@ -1,41 +1,55 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse -from backend.services.users.authentication_service import token_auth +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.users.authentication_service import login_required from backend.services.users.user_service import UserService +router = APIRouter( + prefix="/licenses", + tags=["licenses"], + responses={404: {"description": "Not found"}}, +) -class LicensesActionsAcceptAPI(Resource): - @token_auth.login_required - def post(self, license_id): - """ - Capture user acceptance of license terms - --- - tags: - - licenses - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: license_id - in: path - description: License ID terms have been accepted for - required: true - type: integer - default: 1 - responses: - 200: - description: Terms accepted - 401: - description: Unauthorized - Invalid credentials - 404: - description: User or license not found - 500: - description: Internal Server Error - """ - UserService.accept_license_terms(token_auth.current_user(), license_id) - return {"Success": "Terms Accepted"}, 200 + +@router.post("/{license_id}/actions/accept-for-me/") +async def accept_license( + request: Request, + license_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Capture user acceptance of license terms + --- + tags: + - licenses + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: license_id + in: path + description: License ID terms have been accepted for + required: true + type: integer + default: 1 + responses: + 200: + description: Terms accepted + 401: + description: Unauthorized - Invalid credentials + 404: + description: User or license not found + 500: + description: Internal Server Error + """ + await UserService.accept_license_terms(user.id, license_id, db) + return JSONResponse(content={"Success": "Terms Accepted"}, status_code=200) diff --git a/backend/api/licenses/resources.py b/backend/api/licenses/resources.py index 4b3e78066d..429bb1ad86 100644 --- a/backend/api/licenses/resources.py +++ b/backend/api/licenses/resources.py @@ -1,205 +1,212 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from backend.db import get_db from backend.models.dtos.licenses_dto import LicenseDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.license_service import LicenseService -from backend.services.users.authentication_service import token_auth, tm +from backend.services.users.authentication_service import pm_only +router = APIRouter( + prefix="/licenses", + tags=["licenses"], + responses={404: {"description": "Not found"}}, +) -class LicensesRestAPI(Resource): - @tm.pm_only() - @token_auth.login_required - def post(self): - """ - Creates a new mapping license - --- - tags: - - licenses - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating a new mapping license - schema: - properties: - name: - type: string - default: Public Domain - description: - type: string - default: This imagery is in the public domain. - plainText: - type: string - default: This imagery is in the public domain. - responses: - 201: - description: New license created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - license_dto = LicenseDTO(request.get_json()) - license_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { - "Error": "Unable to create new mapping license", - "SubCode": "InvalidData", - }, 400 - new_license_id = LicenseService.create_licence(license_dto) - return {"licenseId": new_license_id}, 201 +@router.post("/") +async def post_license( + license_dto: LicenseDTO, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(pm_only), +): + """ + Creates a new mapping license + --- + tags: + - licenses + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a new mapping license + schema: + properties: + name: + type: string + default: Public Domain + description: + type: string + default: This imagery is in the public domain. + plainText: + type: string + default: This imagery is in the public domain. + responses: + 201: + description: New license created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + new_license_id = await LicenseService.create_license(license_dto, db) + return JSONResponse(content={"licenseId": new_license_id}, status_code=201) - def get(self, license_id): - """ - Get a specified mapping license - --- - tags: - - licenses - produces: - - application/json - parameters: - - name: license_id - in: path - description: Unique license ID - required: true - type: integer - default: 1 - responses: - 200: - description: License found - 404: - description: License not found - 500: - description: Internal Server Error - """ - license_dto = LicenseService.get_license_as_dto(license_id) - return license_dto.to_primitive(), 200 - @tm.pm_only() - @token_auth.login_required - def patch(self, license_id): - """ - Update a specified mapping license - --- - tags: - - licenses - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: license_id - in: path - description: Unique license ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating a specified mapping license - schema: - properties: - name: - type: string - default: Public Domain - description: - type: string - default: This imagery is in the public domain. - plainText: - type: string - default: This imagery is in the public domain. - responses: - 200: - description: License updated - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - license_dto = LicenseDTO(request.get_json()) - license_dto.license_id = license_id - license_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 +@router.get("/{license_id}/") +async def retrieve_license( + license_id: int, + db: Database = Depends(get_db), +): + """ + Get a specified mapping license + --- + tags: + - licenses + produces: + - application/json + parameters: + - name: license_id + in: path + description: Unique license ID + required: true + type: integer + default: 1 + responses: + 200: + description: License found + 404: + description: License not found + 500: + description: Internal Server Error + """ + license_dto = await LicenseService.get_license_as_dto(license_id, db) + return license_dto - updated_license = LicenseService.update_licence(license_dto) - return updated_license.to_primitive(), 200 - @tm.pm_only() - @token_auth.login_required - def delete(self, license_id): - """ - Delete a specified mapping license - --- - tags: - - licenses - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: license_id - in: path - description: Unique license ID - required: true - type: integer - default: 1 - responses: - 200: - description: License deleted - 401: - description: Unauthorized - Invalid credentials - 404: - description: License not found - 500: - description: Internal Server Error - """ - LicenseService.delete_license(license_id) - return {"Success": "License deleted"}, 200 +@router.patch("/{license_id}/") +async def patch_license( + license_dto: LicenseDTO, + license_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(pm_only), +): + """ + Update a specified mapping license + --- + tags: + - licenses + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: license_id + in: path + description: Unique license ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating a specified mapping license + schema: + properties: + name: + type: string + default: Public Domain + description: + type: string + default: This imagery is in the public domain. + plainText: + type: string + default: This imagery is in the public domain. + responses: + 200: + description: License updated + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + await LicenseService.update_license(license_dto, license_id, db) + return JSONResponse(content={"status": "Updated"}, status_code=200) -class LicensesAllAPI(Resource): - def get(self): - """ - Get all imagery licenses - --- - tags: - - licenses - produces: - - application/json - responses: - 200: - description: Licenses found - 404: - description: Licenses not found - 500: - description: Internal Server Error - """ - licenses_dto = LicenseService.get_all_licenses() - return licenses_dto.to_primitive(), 200 +@router.delete("/{license_id}/") +async def delete_license( + license_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(pm_only), +): + """ + Delete a specified mapping license + --- + tags: + - licenses + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: license_id + in: path + description: Unique license ID + required: true + type: integer + default: 1 + responses: + 200: + description: License deleted + 401: + description: Unauthorized - Invalid credentials + 404: + description: License not found + 500: + description: Internal Server Error + """ + await LicenseService.delete_license(license_id, db) + return JSONResponse(content={"Success": "License deleted"}, status_code=200) + + +@router.get("/") +async def get_licenses(db: Database = Depends(get_db)): + """ + Get all imagery licenses + --- + tags: + - licenses + produces: + - application/json + responses: + 200: + description: Licenses found + 404: + description: Licenses not found + 500: + description: Internal Server Error + """ + licenses_dto = await LicenseService.get_all_licenses(db) + return licenses_dto diff --git a/backend/api/notifications/actions.py b/backend/api/notifications/actions.py index a6fa74c109..daa398a8c3 100644 --- a/backend/api/notifications/actions.py +++ b/backend/api/notifications/actions.py @@ -1,153 +1,179 @@ -from flask_restful import Resource, request +from databases import Database +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import JSONResponse +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.messaging.message_service import MessageService -from backend.services.users.authentication_service import token_auth, tm +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/notifications", + tags=["notifications"], + responses={404: {"description": "Not found"}}, +) -class NotificationsActionsDeleteMultipleAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def delete(self): - """ - Delete specified messages for logged in user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object containing message ids to delete - schema: - properties: - messageIds: - type: array - items: integer - required: true - responses: - 200: - description: Messages deleted - 500: - description: Internal Server Error - """ - message_ids = request.get_json()["messageIds"] - if message_ids: - MessageService.delete_multiple_messages( - message_ids, token_auth.current_user() - ) - return {"Success": "Messages deleted"}, 200 +@router.delete("/delete-multiple/") +async def delete_multiple_notifications( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Delete specified messages for logged in user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object containing message ids to delete + schema: + properties: + messageIds: + type: array + items: integer + required: true + responses: + 200: + description: Messages deleted + 500: + description: Internal Server Error + """ + data = await request.json() + message_ids = data["messageIds"] + if message_ids: + async with db.transaction(): + await MessageService.delete_multiple_messages(message_ids, user.id, db) + return JSONResponse(content={"Success": "Messages deleted"}, status_code=200) -class NotificationsActionsDeleteAllAPI(Resource): - @token_auth.login_required - def delete(self): - """ - Delete all messages for logged in user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: query - name: messageType - type: string - description: Optional message-type filter; leave blank to delete all - responses: - 200: - description: Messages deleted - 500: - description: Internal Server Error - """ - message_type = request.args.get("messageType") - MessageService.delete_all_messages(token_auth.current_user(), message_type) - return {"Success": "Messages deleted"}, 200 +@router.delete("/delete-all/") +async def delete_all_notifications( + message_type: str = Query( + None, + alias="messageType", + description="Optional message-type filter; leave blank to delete all", + ), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Delete all messages for logged in user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: messageType + type: string + description: Optional message-type filter; leave blank to delete all + responses: + 200: + description: Messages deleted + 500: + description: Internal Server Error + """ + async with db.transaction(): + await MessageService.delete_all_messages(user.id, db, message_type) + return JSONResponse(content={"Success": "Messages deleted"}, status_code=200) -class NotificationsActionsMarkAsReadAllAPI(Resource): - @token_auth.login_required - def post(self): - """ - Mark all messages as read for logged in user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: query - name: messageType - type: string - description: Optional message-type filter; leave blank to mark all as read - responses: - 200: - description: Messages marked as read - 500: - description: Internal Server Error - """ - message_type = request.args.get("messageType") - MessageService.mark_all_messages_read(token_auth.current_user(), message_type) - return {"Success": "Messages marked as read"}, 200 +@router.post("/mark-as-read-all/") +async def mark_all_as_read( + message_type: str = Query( + None, + alias="messageType", + description="Optional message-type filter; leave blank to mark all as read", + ), + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Mark all messages as read for logged in user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: messageType + type: string + description: Optional message-type filter; leave blank to mark all as read + responses: + 200: + description: Messages marked as read + 500: + description: Internal Server Error + """ + await MessageService.mark_all_messages_read(user.id, db, message_type) + return JSONResponse(content={"Success": "Messages marked as read"}, status_code=200) -class NotificationsActionsMarkAsReadMultipleAPI(Resource): - @token_auth.login_required - def post(self): - """ - Mark specified messages as read for logged in user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object containing message ids to mark as read - schema: - properties: - messageIds: - type: array - items: integer - required: true - responses: - 200: - description: Messages marked as read - 500: - description: Internal Server Error - """ - message_ids = request.get_json()["messageIds"] - if message_ids: - MessageService.mark_multiple_messages_read( - message_ids, token_auth.current_user() - ) +@router.post("/mark-as-read-multiple/") +async def mark_multiple_as_read( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Mark specified messages as read for logged in user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object containing message ids to mark as read + schema: + properties: + messageIds: + type: array + items: integer + required: true + responses: + 200: + description: Messages marked as read + 500: + description: Internal Server Error + """ + data = await request.json() + message_ids = data["messageIds"] + if message_ids: + await MessageService.mark_multiple_messages_read(message_ids, user.id, db) - return {"Success": "Messages marked as read"}, 200 + return JSONResponse(content={"Success": "Messages marked as read"}, status_code=200) diff --git a/backend/api/notifications/resources.py b/backend/api/notifications/resources.py index 6575fad152..0149f9418f 100644 --- a/backend/api/notifications/resources.py +++ b/backend/api/notifications/resources.py @@ -1,241 +1,305 @@ -from flask_restful import Resource, request +from databases import Database +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import JSONResponse + +from backend.db import get_db +from backend.models.dtos.message_dto import MessageDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.messaging.message_service import ( MessageService, MessageServiceError, ) from backend.services.notification_service import NotificationService -from backend.services.users.authentication_service import token_auth, tm +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/notifications", + tags=["notifications"], + responses={404: {"description": "Not found"}}, +) -class NotificationsRestAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def get(self, message_id): - """ - Gets the specified message - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: message_id - in: path - description: The unique message - required: true - type: integer - default: 1 - responses: - 200: - description: Messages found - 403: - description: Forbidden, if user attempting to ready other messages - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - user_message = MessageService.get_message_as_dto( - message_id, token_auth.current_user() - ) - return user_message.to_primitive(), 200 - except MessageServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - @tm.pm_only(False) - @token_auth.login_required - def delete(self, message_id): - """ - Deletes the specified message - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: message_id - in: path - description: The unique message - required: true - type: integer - default: 1 - responses: - 200: - description: Messages found - 403: - description: Forbidden, if user attempting to ready other messages - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - MessageService.delete_message(message_id, token_auth.current_user()) - return {"Success": "Message deleted"}, 200 - except MessageServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.get("/{message_id}/", response_model=MessageDTO) +async def retrieve_notification( + message_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Gets the specified message + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: message_id + in: path + description: The unique message + required: true + type: integer + default: 1 + responses: + 200: + description: Messages found + 403: + description: Forbidden, if user attempting to ready other messages + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + user_message = await MessageService.get_message_as_dto(message_id, user.id, db) + return user_message + except MessageServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class NotificationsAllAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def get(self): - """ - Get all messages for logged in user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: query - name: messageType - type: string - description: Optional message-type filter; leave blank to retrieve all\n - Accepted values are 1 (System), 2 (Broadcast), 3 (Mention), 4 (Validation), - 5 (Invalidation), 6 (Request team), \n - 7 (Invitation), 8 (Task comment), 9 (Project chat), - 10 (Project Activity), and 11 (Team broadcast) - - in: query - name: from - description: Optional from username filter - type: string - - in: query - name: project - description: Optional project filter - type: string - - in: query - name: taskId - description: Optional task filter - type: integer - - in: query - name: status - description: Optional status filter (read or unread) - type: string - - in: query - name: sortBy - description: - field to sort by, defaults to 'date'. Other useful options are 'read', 'project_id' and 'message_type' - type: string - - in: query - name: sortDirection - description: sorting direction ('asc' or 'desc'), defaults to 'desc' - type: string - - in: query - name: page - description: Page of results - type: integer - - in: query - name: pageSize - description: Size of page, defaults to 10 - type: integer - responses: - 200: - description: Messages found - 404: - description: User has no messages - 500: - description: Internal Server Error - """ - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - page = request.args.get("page", 1, int) - page_size = request.args.get("pageSize", 10, int) - sort_by = request.args.get("sortBy", "date") - sort_direction = request.args.get("sortDirection", "desc") - message_type = request.args.get("messageType", None) - from_username = request.args.get("from") - project = request.args.get("project", None, int) - task_id = request.args.get("taskId", None, int) - status = request.args.get("status", None, str) - user_messages = MessageService.get_all_messages( - token_auth.current_user(), - preferred_locale, - page, - page_size, - sort_by, - sort_direction, - message_type, - from_username, - project, - task_id, - status, +@router.delete("/{message_id}/") +async def delete_notification( + message_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Deletes the specified message + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: message_id + in: path + description: The unique message + required: true + type: integer + default: 1 + responses: + 200: + description: Messages found + 403: + description: Forbidden, if user attempting to ready other messages + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + async with db.transaction(): + await MessageService.delete_message(message_id, user.id, db) + return JSONResponse(content={"Success": "Message deleted"}, status_code=200) + except MessageServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, ) - return user_messages.to_primitive(), 200 -class NotificationsQueriesCountUnreadAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def get(self): - """ - Gets count of unread messages - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Message info - 500: - description: Internal Server Error - """ - unread_count = MessageService.has_user_new_messages(token_auth.current_user()) - return unread_count, 200 +@router.get("/") +async def get_notifications( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), + message_type: str | None = Query( + default=None, + description=( + "Optional message-type filter; leave blank to retrieve all.\n" + "Accepted values: 1 (System), 2 (Broadcast), 3 (Mention), 4 (Validation),\n" + "5 (Invalidation), 6 (Request team), 7 (Invitation), 8 (Task comment),\n" + "9 (Project chat), 10 (Project Activity), 11 (Team broadcast)" + ), + alias="messageType", + ), + from_username: str | None = Query( + default=None, + alias="from", + description="Optional from username filter", + ), + project: str | None = Query( + default=None, + description="Optional project filter", + ), + task_id: int | None = Query( + default=None, + description="Optional task filter", + alias="taskId", + ), + status: str | None = Query( + default=None, + description="Optional status filter (read or unread)", + ), + sort_by: str = Query( + default="date", + description="Field to sort by. Options: 'date', 'read', 'project_id', 'message_type'", + alias="sortBy", + ), + sort_direction: str = Query( + default="desc", + description="Sorting direction: 'asc' or 'desc'", + alias="sortDirection", + ), + page: int = Query( + default=1, + description="Page number", + ), + page_size: int = Query( + default=10, + alias="pageSize", + description="Number of results per page", + ), +): + """ + Get all messages for logged in user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: messageType + type: string + description: Optional message-type filter; leave blank to retrieve all\n + Accepted values are 1 (System), 2 (Broadcast), 3 (Mention), 4 (Validation), + 5 (Invalidation), 6 (Request team), \n + 7 (Invitation), 8 (Task comment), 9 (Project chat), + 10 (Project Activity), and 11 (Team broadcast) + - in: query + name: from + description: Optional from username filter + type: string + - in: query + name: project + description: Optional project filter + type: string + - in: query + name: taskId + description: Optional task filter + type: integer + - in: query + name: status + description: Optional status filter (read or unread) + type: string + - in: query + name: sortBy + description: + field to sort by, defaults to 'date'. Other useful options are 'read', 'project_id' and 'message_type' + type: string + - in: query + name: sortDirection + description: sorting direction ('asc' or 'desc'), defaults to 'desc' + type: string + - in: query + name: page + description: Page of results + type: integer + - in: query + name: pageSize + description: Size of page, defaults to 10 + type: integer + responses: + 200: + description: Messages found + 404: + description: User has no messages + 500: + description: Internal Server Error + """ + preferred_locale = request.headers.get("accept-language") + + user_messages = await MessageService.get_all_messages( + db, + user.id, + preferred_locale, + page, + page_size, + sort_by, + sort_direction, + message_type, + from_username, + project, + task_id, + status, + ) + return user_messages + + +@router.get("/queries/own/count-unread/") +async def get_unreads( + user: AuthUserDTO = Depends(login_required), db: Database = Depends(get_db) +): + """ + Gets count of unread messages + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Message info + 500: + description: Internal Server Error + """ + unread_count = await MessageService.has_user_new_messages(user.id, db) + return unread_count -class NotificationsQueriesPostUnreadAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def post(self): - """ - Updates notification datetime for user - --- - tags: - - notifications - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 404: - description: Notification not found. - 200: - description: Message info - 500: - description: Internal Server Error - """ - user_id = token_auth.current_user() - unread_count = NotificationService.update(user_id) - return unread_count, 200 +@router.post("/queries/own/post-unread/") +async def post_unreads( + user: AuthUserDTO = Depends(login_required), db: Database = Depends(get_db) +): + """ + Updates notification datetime for user + --- + tags: + - notifications + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 404: + description: Notification not found. + 200: + description: Message info + 500: + description: Internal Server Error + """ + unread_count = await NotificationService.update(user.id, db) + return unread_count diff --git a/backend/api/organisations/campaigns.py b/backend/api/organisations/campaigns.py index 9abaf40440..8945ab4ee5 100644 --- a/backend/api/organisations/campaigns.py +++ b/backend/api/organisations/campaigns.py @@ -1,155 +1,200 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from backend.db import get_db +from backend.models.dtos.campaign_dto import CampaignListDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.campaign_service import CampaignService from backend.services.organisation_service import OrganisationService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/organisations", + tags=["organisations"], + responses={404: {"description": "Not found"}}, +) -class OrganisationsCampaignsAPI(Resource): - @token_auth.login_required - def post(self, organisation_id, campaign_id): - """ - Assigns a campaign to an organisation - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: Unique organisation ID - required: true - type: integer - default: 1 - - name: campaign_id - in: path - description: Unique campaign ID - required: true - type: integer - default: 1 - responses: - 200: - description: Organisation and campaign assigned successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - users have submitted mapping - 404: - description: Project not found - 500: - description: Internal Server Error - """ - if OrganisationService.can_user_manage_organisation( - organisation_id, token_auth.current_user() + +@router.post("/{organisation_id}/campaigns/{campaign_id}/") +async def post_organisation_campaign( + request: Request, + organisation_id: int, + campaign_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Assigns a campaign to an organisation + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: Unique organisation ID + required: true + type: integer + default: 1 + - name: campaign_id + in: path + description: Unique campaign ID + required: true + type: integer + default: 1 + responses: + 200: + description: Organisation and campaign assigned successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden - users have submitted mapping + 404: + description: Project not found + 500: + description: Internal Server Error + """ + if await OrganisationService.can_user_manage_organisation( + organisation_id, request.user.display_name, db + ): + if await CampaignService.campaign_organisation_exists( + campaign_id, organisation_id, db ): - if CampaignService.campaign_organisation_exists( + message = "Campaign {} is already assigned to organisation {}.".format( campaign_id, organisation_id - ): - message = "Campaign {} is already assigned to organisation {}.".format( - campaign_id, organisation_id - ) - return {"Error": message, "SubCode": "CampaignAlreadyAssigned"}, 400 - - CampaignService.create_campaign_organisation(organisation_id, campaign_id) + ) + return JSONResponse( + content={"Error": message, "SubCode": "CampaignAlreadyAssigned"}, + status_code=400, + ) + async with db.transaction(): + await CampaignService.create_campaign_organisation( + organisation_id, campaign_id, db + ) message = "campaign with id {} assigned for organisation with id {}".format( campaign_id, organisation_id ) - return {"Success": message}, 200 - else: - return { + return JSONResponse(content={"Success": message}, status_code=200) + else: + return JSONResponse( + content={ "Error": "User is not a manager of the organisation", "SubCode": "UserNotPermitted", - }, 403 + }, + status_code=403, + ) - def get(self, organisation_id): - """ - Returns all campaigns related to an organisation - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: false - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Success - 404: - description: Organisation not found - 500: - description: Internal Server Error - """ - campaigns = CampaignService.get_organisation_campaigns_as_dto(organisation_id) - return campaigns.to_primitive(), 200 - @token_auth.login_required - def delete(self, organisation_id, campaign_id): - """ - Un-assigns an organization from an campaign - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: Unique organisation ID - required: true - type: integer - default: 1 - - name: campaign_id - in: path - description: Unique campaign ID - required: true - type: integer - default: 1 - responses: - 200: - description: Organisation and campaign unassociated successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - users have submitted mapping - 404: - description: Project not found - 500: - description: Internal Server Error - """ - if OrganisationService.can_user_manage_organisation( - organisation_id, token_auth.current_user() - ): - CampaignService.delete_organisation_campaign(organisation_id, campaign_id) - return ( - {"Success": "Organisation and campaign unassociated successfully"}, - 200, +@router.get("/{organisation_id}/campaigns/", response_model=CampaignListDTO) +async def get_organisation_campaigns( + organisation_id: int, db: Database = Depends(get_db) +): + """ + Returns all campaigns related to an organisation + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: false + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Success + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + campaigns = await CampaignService.get_organisation_campaigns_as_dto( + organisation_id, db + ) + return campaigns + + +@router.delete("/{organisation_id}/campaigns/{campaign_id}/") +async def delete_organisation_campaign( + request: Request, + organisation_id: int, + campaign_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Un-assigns an organization from an campaign + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: Unique organisation ID + required: true + type: integer + default: 1 + - name: campaign_id + in: path + description: Unique campaign ID + required: true + type: integer + default: 1 + responses: + 200: + description: Organisation and campaign unassociated successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden - users have submitted mapping + 404: + description: Project not found + 500: + description: Internal Server Error + """ + if await OrganisationService.can_user_manage_organisation( + organisation_id, request.user.display_name, db + ): + async with db.transaction(): + await CampaignService.delete_organisation_campaign( + organisation_id, campaign_id, db + ) + return JSONResponse( + content={ + "Success": "Organisation and campaign unassociated successfully" + }, + status_code=200, ) - else: - return { + else: + return JSONResponse( + content={ "Error": "User is not a manager of the organisation", "SubCode": "UserNotPermitted", - }, 403 + }, + status_code=403, + ) diff --git a/backend/api/organisations/resources.py b/backend/api/organisations/resources.py index 86e2badb0b..89527a339d 100644 --- a/backend/api/organisations/resources.py +++ b/backend/api/organisations/resources.py @@ -1,436 +1,519 @@ -from distutils.util import strtobool -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from loguru import logger +from backend.db import get_db from backend.models.dtos.organisation_dto import ( + ListOrganisationsDTO, NewOrganisationDTO, + OrganisationDTO, UpdateOrganisationDTO, ) +from backend.models.dtos.stats_dto import OrganizationStatsDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.user import User from backend.services.organisation_service import ( OrganisationService, OrganisationServiceError, ) -from backend.models.postgis.statuses import OrganisationType -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/organisations", + tags=["organisations"], + responses={404: {"description": "Not found"}}, +) -class OrganisationsBySlugRestAPI(Resource): - @token_auth.login_required(optional=True) - def get(self, slug): - """ - Retrieves an organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - default: Token sessionTokenHere== - - name: slug - in: path - description: The unique organisation slug - required: true - type: string - default: hot - - in: query - name: omitManagerList - type: boolean - description: Set it to true if you don't want the managers list on the response. - default: False - responses: - 200: - description: Organisation found - 404: - description: Organisation not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if authenticated_user_id is None: - user_id = 0 - else: - user_id = authenticated_user_id - # Validate abbreviated. - omit_managers = strtobool(request.args.get("omitManagerList", "false")) - organisation_dto = OrganisationService.get_organisation_by_slug_as_dto( - slug, user_id, omit_managers - ) - return organisation_dto.to_primitive(), 200 +@router.get("/{organisation_id:int}/", response_model=OrganisationDTO) +async def retrieve_organisation( + request: Request, + organisation_id: int, + db: Database = Depends(get_db), + omit_managers: bool = Query( + False, + alias="omitManagerList", + description="Omit organization managers list from the response.", + ), +): + """ + Retrieves an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: The unique organisation ID + required: true + type: integer + default: 1 + - in: query + name: omitManagerList + type: boolean + description: Set it to true if you don't want the managers list on the response. + default: False + responses: + 200: + description: Organisation found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if authenticated_user_id is None: + user_id = 0 + else: + user_id = authenticated_user_id + # Validate abbreviated. + organisation_dto = await OrganisationService.get_organisation_by_id_as_dto( + organisation_id, user_id, omit_managers, db + ) + return organisation_dto -class OrganisationsRestAPI(Resource): - @token_auth.login_required - def post(self): - """ - Creates a new organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating organisation - schema: - properties: - name: - type: string - default: HOT - slug: - type: string - default: hot - logo: - type: string - default: https://cdn.hotosm.org/tasking-manager/uploads/1588741335578_hot-logo.png - url: + +@router.get("/{slug:str}/", response_model=OrganisationDTO) +async def retrieve_organisation_by_slug( + request: Request, + slug: str, + db: Database = Depends(get_db), + omit_managers: bool = Query( + False, + alias="omitManagerList", + description="Omit organization managers list from the response.", + ), +): + """ + Retrieves an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - name: slug + in: path + description: The unique organisation slug + required: true + type: string + default: hot + - in: query + name: omitManagerList + type: boolean + description: Set it to true if you don't want the managers list on the response. + default: False + responses: + 200: + description: Organisation found + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if authenticated_user_id is None: + user_id = 0 + else: + user_id = authenticated_user_id + organisation_dto = await OrganisationService.get_organisation_by_slug_as_dto( + slug, user_id, omit_managers, db + ) + return organisation_dto + + +@router.post("/") +async def create_organisation( + organisation_dto: NewOrganisationDTO, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Creates a new organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating organisation + schema: + properties: + name: + type: string + default: HOT + slug: + type: string + default: hot + logo: + type: string + default: https://cdn.hotosm.org/tasking-manager/uploads/1588741335578_hot-logo.png + url: + type: string + default: https://hotosm.org + managers: + type: array + items: type: string - default: https://hotosm.org - managers: - type: array - items: - type: string - default: [ - user_1, - user_2 - ] - responses: - 201: - description: Organisation created successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 402: - description: Duplicate Name - Organisation name already exists - 500: - description: Internal Server Error - """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + default: [ + user_1, + user_2 + ] + responses: + 201: + description: Organisation created successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 402: + description: Duplicate Name - Organisation name already exists + 500: + description: Internal Server Error + """ + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can create organisations.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) + try: + if request_user.username not in organisation_dto.managers: + organisation_dto.managers.append(request_user.username) + + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) - try: - organisation_dto = NewOrganisationDTO(request.get_json()) - if request_user.username not in organisation_dto.managers: - organisation_dto.managers.append(request_user.username) - organisation_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 + try: + async with db.transaction(): + org_id = await OrganisationService.create_organisation(organisation_dto, db) + return JSONResponse(content={"organisationId": org_id}, status_code=201) + except OrganisationServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) - try: - org_id = OrganisationService.create_organisation(organisation_dto) - return {"organisationId": org_id}, 201 - except OrganisationServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - @token_auth.login_required - def delete(self, organisation_id): - """ - Deletes an organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: The unique organisation ID - required: true - type: integer - default: 1 - responses: - 200: - description: Organisation deleted - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Organisation not found - 500: - description: Internal Server Error - """ - if not OrganisationService.can_user_manage_organisation( - organisation_id, token_auth.current_user() - ): - return { +@router.delete("/{organisation_id}/") +async def delete_organisation( + request: Request, + organisation_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Deletes an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: The unique organisation ID + required: true + type: integer + default: 1 + responses: + 200: + description: Organisation deleted + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + if not await OrganisationService.can_user_manage_organisation( + organisation_id, user.id, db + ): + return JSONResponse( + content={ "Error": "User is not an admin for the org", "SubCode": "UserNotOrgAdmin", - }, 403 - try: - OrganisationService.delete_organisation(organisation_id) - return {"Success": "Organisation deleted"}, 200 - except OrganisationServiceError: - return { + }, + status_code=403, + ) + try: + async with db.transaction(): + await OrganisationService.delete_organisation(organisation_id, db) + return JSONResponse( + content={"Success": "Organisation deleted"}, status_code=200 + ) + + except OrganisationServiceError: + return JSONResponse( + content={ "Error": "Organisation has some projects", "SubCode": "OrgHasProjects", - }, 403 - - @token_auth.login_required(optional=True) - def get(self, organisation_id): - """ - Retrieves an organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: The unique organisation ID - required: true - type: integer - default: 1 - - in: query - name: omitManagerList - type: boolean - description: Set it to true if you don't want the managers list on the response. - default: False - responses: - 200: - description: Organisation found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Organisation not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if authenticated_user_id is None: - user_id = 0 - else: - user_id = authenticated_user_id - # Validate abbreviated. - omit_managers = strtobool(request.args.get("omitManagerList", "false")) - organisation_dto = OrganisationService.get_organisation_by_id_as_dto( - organisation_id, user_id, omit_managers + }, + status_code=403, ) - return organisation_dto.to_primitive(), 200 - @token_auth.login_required - def patch(self, organisation_id): - """ - Updates an organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: organisation_id - in: path - description: The unique organisation ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating an organisation - schema: - properties: - name: - type: string - default: HOT - slug: - type: string - default: HOT - logo: - type: string - default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - url: + +@router.patch("/{organisation_id}/") +async def update_organisation( + organisation_dto: UpdateOrganisationDTO, + request: Request, + organisation_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Updates an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: The unique organisation ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating an organisation + schema: + properties: + name: + type: string + default: HOT + slug: + type: string + default: HOT + logo: + type: string + default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + url: + type: string + default: https://hotosm.org + managers: + type: array + items: type: string - default: https://hotosm.org - managers: - type: array - items: - type: string - default: [ - user_1, - user_2 - ] - responses: - 201: - description: Organisation updated successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - if not OrganisationService.can_user_manage_organisation( - organisation_id, token_auth.current_user() - ): - return { + default: [ + user_1, + user_2 + ] + responses: + 201: + description: Organisation updated successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + if not await OrganisationService.can_user_manage_organisation( + organisation_id, user.id, db + ): + return JSONResponse( + content={ "Error": "User is not an admin for the org", "SubCode": "UserNotOrgAdmin", - }, 403 - try: - organisation_dto = UpdateOrganisationDTO(request.get_json()) - organisation_dto.organisation_id = organisation_id - # Don't update organisation type and subscription_tier if request user is not an admin - if User.get_by_id(token_auth.current_user()).role != 1: - org = OrganisationService.get_organisation_by_id(organisation_id) - organisation_dto.type = OrganisationType(org.type).name - organisation_dto.subscription_tier = org.subscription_tier - organisation_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - - try: - OrganisationService.update_organisation(organisation_dto) - return {"Status": "Updated"}, 200 - except OrganisationServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 402 - - -class OrganisationsStatsAPI(Resource): - def get(self, organisation_id): - """ - Return statistics about projects and active tasks of an organisation - --- - tags: - - organisations - produces: - - application/json - parameters: - - name: organisation_id - in: path - description: The unique organisation ID - required: true - type: integer - default: 1 - responses: - 200: - description: Organisation found - 404: - description: Organisation not found - 500: - description: Internal Server Error - """ - OrganisationService.get_organisation_by_id(organisation_id) - organisation_dto = OrganisationService.get_organisation_stats( - organisation_id, None + }, + status_code=403, + ) + try: + organisation_dto.organisation_id = organisation_id + # Don't update organisation type and subscription_tier if request user is not an admin + user = await User.get_by_id(user.id, db) + if user.role != 1: + org = await OrganisationService.get_organisation_by_id(organisation_id, db) + organisation_dto.type = org.type + organisation_dto.subscription_tier = org.subscription_tier + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) + try: + async with db.transaction(): + await OrganisationService.update_organisation(organisation_dto, db) + return JSONResponse(content={"Status": "Updated"}, status_code=200) + except OrganisationServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=402, ) - return organisation_dto.to_primitive(), 200 -class OrganisationsAllAPI(Resource): - @token_auth.login_required(optional=True) - def get(self): - """ - List all organisations - --- - tags: - - organisations - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - default: Token sessionTokenHere== - - name: manager_user_id - in: query - description: Filter projects on managers with this user_id - required: false - type: integer - - in: query - name: omitManagerList - type: boolean - description: Set it to true if you don't want the managers list on the response. - default: False - - in: query - name: omitOrgStats - type: boolean - description: Set it to true if you don't want organisation stats on the response. \n - \n - Adds year to date organisation stats to response if set false. - default: True +@router.get("/{organisation_id}/statistics/", response_model=OrganizationStatsDTO) +async def get_organisation_with_statistics( + request: Request, + organisation_id: int, + db: Database = Depends(get_db), +): + """ + Return statistics about projects and active tasks of an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - name: organisation_id + in: path + description: The unique organisation ID + required: true + type: integer + default: 1 + responses: + 200: + description: Organisation found + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + await OrganisationService.get_organisation_by_id(organisation_id, db) + organisation_dto = await OrganisationService.get_organisation_stats( + organisation_id, db, None + ) + return organisation_dto - responses: - 200: - description: Organisations found - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Unauthorized - Not allowed - 404: - description: Organisations not found - 500: - description: Internal Server Error - """ - # Restrict some of the parameters to some permissions - authenticated_user_id = token_auth.current_user() - try: - manager_user_id = int(request.args.get("manager_user_id")) - except Exception: - manager_user_id = None +@router.get("/", response_model=ListOrganisationsDTO) +async def list_organisation( + request: Request, + db: Database = Depends(get_db), + omit_stats: bool = Query( + True, + alias="omitOrgStats", + description="Omit organization stats from the response.", + ), + omit_managers: bool = Query( + False, + alias="omitManagerList", + description="Omit organization managers list from the response.", + ), + manager_user_id: int = Query( + None, alias="manager_user_id", description="ID of the manager user." + ), +): + """ + List all organisations + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - name: manager_user_id + in: query + description: Filter projects on managers with this user_id + required: false + type: integer + - in: query + name: omitManagerList + type: boolean + description: Set it to true if you don't want the managers list on the response. + default: False + - in: query + name: omitOrgStats + type: boolean + description: Set it to true if you don't want organisation stats on the response. \n + \n + Adds year to date organisation stats to response if set false. + default: True - if manager_user_id is not None and not authenticated_user_id: - return ( - { - "Error": "Unauthorized - Filter by manager_user_id is not allowed to unauthenticated requests", - "SubCode": "LoginToFilterManager", - }, - 403, - ) + responses: + 200: + description: Organisations found + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Unauthorized - Not allowed + 404: + description: Organisations not found + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) - # Validate abbreviated. - omit_managers = bool(strtobool(request.args.get("omitManagerList", "false"))) - omit_stats = bool(strtobool(request.args.get("omitOrgStats", "true"))) - # Obtain organisations - results_dto = OrganisationService.get_organisations_as_dto( - manager_user_id, - authenticated_user_id, - omit_managers, - omit_stats, + if manager_user_id is not None and not authenticated_user_id: + return JSONResponse( + content={ + "Error": "Unauthorized - Filter by manager_user_id is not allowed to unauthenticated requests", + "SubCode": "LoginToFilterManager", + }, + status_code=403, ) - return results_dto.to_primitive(), 200 + results_dto = await OrganisationService.get_organisations_as_dto( + manager_user_id, authenticated_user_id, omit_managers, omit_stats, db + ) + return results_dto diff --git a/backend/api/partners/resources.py b/backend/api/partners/resources.py index bd3b4ff247..4db705baf6 100644 --- a/backend/api/partners/resources.py +++ b/backend/api/partners/resources.py @@ -1,400 +1,475 @@ -from flask_restful import Resource, request +import json -from backend.services.partner_service import PartnerService, PartnerServiceError -from backend.services.users.authentication_service import token_auth +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from backend.db import get_db +from backend.models.dtos.partner_dto import PartnerDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.user import User +from backend.services.partner_service import PartnerService, PartnerServiceError +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/partners", + tags=["partners"], + responses={404: {"description": "Not found"}}, +) -class PartnerRestAPI(Resource): - @token_auth.login_required - def get(self, partner_id): - """ - Get partner by id - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partner_id - in: path - description: The id of the partner - required: true - type: integer - default: 1 - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { +@router.get("/{partner_id:int}/") +async def retrieve_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get partner by id + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partner_id + in: path + description: The id of the partner + required: true + type: integer + default: 1 + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - partner = PartnerService.get_partner_by_id(partner_id) - if partner: - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - return partner_dict, 200 - else: - return {"message": "Partner not found"}, 404 + partner = await PartnerService.get_partner_by_id(partner_id, db) + if partner: + partner_dto = PartnerDTO.from_record(partner) + partner_dict = partner_dto.dict() + website_links = partner_dict.pop("website_links", []) + for i, link in enumerate(website_links, start=1): + partner_dict[f"name_{i}"] = link.get("name") + partner_dict[f"url_{i}"] = link.get("url") - @token_auth.login_required - def delete(self, partner_id): - """ - Deletes an existing partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - name: partner_id - in: path - description: Partner ID - required: true - type: integer - default: 1 - responses: - 200: - description: Partner deleted successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + return partner_dict + else: + return JSONResponse(content={"message": "Partner not found"}, status_code=404) + + +@router.delete("/{partner_id}/") +async def delete_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Deletes an existing partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - name: partner_id + in: path + description: Partner ID + required: true + type: integer + default: 1 + responses: + 200: + description: Partner deleted successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - PartnerService.delete_partner(partner_id) - return {"Success": "Partner deleted"}, 200 - except PartnerServiceError as e: - return {"message": str(e)}, 404 + try: + async with db.transaction(): + await PartnerService.delete_partner(partner_id, db) + return JSONResponse(content={"Success": "Partner deleted"}, status_code=200) + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=404) - @token_auth.login_required - def put(self, partner_id): - """ - Updates an existing partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - name: partner_id - in: path - description: Partner ID - required: true - type: integer - - in: body - name: body - required: true - description: JSON object for updating a Partner - schema: - properties: - name: - type: string - example: Cool Partner Inc. - primary_hashtag: - type: string - example: CoolPartner - secondary_hashtag: - type: string - example: CoolPartner,coolProject-* - link_x: - type: string - example: https://x.com/CoolPartner - link_meta: - type: string - example: https://facebook.com/CoolPartner - link_instagram: - type: string - example: https://instagram.com/CoolPartner - current_projects: - type: string - example: 3425,2134,2643 - permalink: - type: string - example: cool-partner - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - website_links: - type: array - items: - type: string - mapswipe_group_id: + +@router.put("/{partner_id}/") +async def update_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Updates an existing partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - name: partner_id + in: path + description: Partner ID + required: true + type: integer + - in: body + name: body + required: true + description: JSON object for updating a Partner + schema: + properties: + name: + type: string + example: Cool Partner Inc. + primary_hashtag: + type: string + example: CoolPartner + secondary_hashtag: + type: string + example: CoolPartner,coolProject-* + link_x: + type: string + example: https://x.com/CoolPartner + link_meta: + type: string + example: https://facebook.com/CoolPartner + link_instagram: + type: string + example: https://instagram.com/CoolPartner + current_projects: + type: string + example: 3425,2134,2643 + permalink: + type: string + example: cool-partner + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + website_links: + type: array + items: type: string - example: -NL6WXPOdFyWACqwNU2O - responses: - 200: - description: Partner updated successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Partner not found - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ + mapswipe_group_id: + type: string + example: -NL6WXPOdFyWACqwNU2O + responses: + 200: + description: Partner updated successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Partner not found + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - data = request.json - updated_partner = PartnerService.update_partner(partner_id, data) - updated_partner_dict = updated_partner.as_dto().to_primitive() - return updated_partner_dict, 200 - except PartnerServiceError as e: - return {"message": str(e)}, 404 + try: + data = await request.json() + async with db.transaction(): + updated_partner = await PartnerService.update_partner(partner_id, data, db) + return updated_partner + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=404) -class PartnersAllRestAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get all active partners - --- - tags: - - partners - produces: - - application/json - responses: - 200: - description: All Partners returned successfully - 500: - description: Internal Server Error - """ +@router.get("/") +async def list_partners( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get all active partners + --- + tags: + - partners + produces: + - application/json + responses: + 200: + description: All Partners returned successfully + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - partner_ids = PartnerService.get_all_partners() - partners = [] - for partner_id in partner_ids: - partner = PartnerService.get_partner_by_id(partner_id) - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - partners.append(partner_dict) - return partners, 200 + partner_ids = await PartnerService.get_all_partners(db) + partners = [] + for partner_id in partner_ids: + partner = await PartnerService.get_partner_by_id(partner_id, db) + partner_dict = PartnerDTO.from_record(partner).dict() + website_links = partner_dict.pop("website_links", []) + for i, link in enumerate(website_links, start=1): + partner_dict[f"name_{i}"] = link.get("name") + partner_dict[f"url_{i}"] = link.get("url") + partners.append(partner_dict) - @token_auth.login_required - def post(self): - """ - Creates a new partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - in: body - name: body - required: true - description: JSON object for creating a new Partner - schema: - properties: - name: - type: string - required: true - example: "American red cross" - primary_hashtag: - type: string - required: true - example: "#americanredcross" - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - name: - type: string - example: Cool Partner Inc. - primary_hashtag: - type: string - example: CoolPartner - secondary_hashtag: - type: string - example: CoolPartner,coolProject-* - link_x: - type: string - example: https://x.com/CoolPartner - link_meta: - type: string - example: https://facebook.com/CoolPartner - link_instagram: - type: string - example: https://instagram.com/CoolPartner - current_projects: - type: string - example: 3425,2134,2643 - permalink: - type: string - example: cool-partner - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - website_links: - type: array - items: - type: string - default: [ - ] - mapswipe_group_id: + return partners + + +@router.post("/") +async def create_partner( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Creates a new partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - in: body + name: body + required: true + description: JSON object for creating a new Partner + schema: + properties: + name: + type: string + required: true + example: "American red cross" + primary_hashtag: + type: string + required: true + example: "#americanredcross" + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + name: + type: string + example: Cool Partner Inc. + primary_hashtag: + type: string + example: CoolPartner + secondary_hashtag: + type: string + example: CoolPartner,coolProject-* + link_x: + type: string + example: https://x.com/CoolPartner + link_meta: + type: string + example: https://facebook.com/CoolPartner + link_instagram: + type: string + example: https://instagram.com/CoolPartner + current_projects: + type: string + example: 3425,2134,2643 + permalink: + type: string + example: cool-partner + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + website_links: + type: array + items: type: string - example: -NL6WXPOdFyWACqwNU2O - responses: - 201: - description: New partner created successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ + default: [ + ] + mapswipe_group_id: + type: string + example: -NL6WXPOdFyWACqwNU2O + responses: + 201: + description: New partner created successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - data = request.json - if data: - if data.get("name") is None: - return {"message": "Partner name is not provided"}, 400 + try: + data = await request.json() + if data: + if data.get("name") is None: + return JSONResponse( + content={"message": "Partner name is not provided"}, status_code=400 + ) - if data.get("primary_hashtag") is None: - return {"message": "Partner primary_hashtag is not provided"}, 400 + if data.get("primary_hashtag") is None: + return JSONResponse( + content={"message": "Partner primary_hashtag is not provided"}, + status_code=400, + ) + async with db.transaction(): + new_partner_id = await PartnerService.create_partner(data, db) + partner_data = await PartnerService.get_partner_by_id(new_partner_id, db) + return partner_data - new_partner = PartnerService.create_partner(data) - partner_dict = new_partner.as_dto().to_primitive() - return partner_dict, 201 - else: - return {"message": "Data not provided"}, 400 - except PartnerServiceError as e: - return {"message": str(e)}, 500 + else: + return JSONResponse( + content={"message": "Data not provided"}, status_code=400 + ) + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=500) -class PartnerPermalinkRestAPI(Resource): - def get(self, permalink): - """ - Get partner by permalink - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - partner = PartnerService.get_partner_by_permalink(permalink) - if partner: - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - return partner_dict, 200 - else: - return {"message": "Partner not found"}, 404 +@router.get("/{permalink:str}/") +async def get_partner( + request: Request, + permalink: str, + db: Database = Depends(get_db), +): + """ + Get partner by permalink + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + try: + partner_record = await PartnerService.get_partner_by_permalink(permalink, db) + if not partner_record: + return JSONResponse( + content={"message": "Partner not found"}, status_code=404 + ) + + partner = dict(partner_record) + website_links = json.loads(partner.get("website_links", "[]")) + for i, link in enumerate(website_links, start=1): + partner[f"name_{i}"] = link["name"] + partner[f"url_{i}"] = link["url"] + + partner.pop("website_links", None) + return partner + except Exception as e: + return JSONResponse(content={"message": str(e)}, status_code=500) diff --git a/backend/api/partners/statistics.py b/backend/api/partners/statistics.py index 6d661fec99..de6469d660 100644 --- a/backend/api/partners/statistics.py +++ b/backend/api/partners/statistics.py @@ -1,15 +1,14 @@ import io -from flask import send_file -from flask_restful import Resource, request from typing import Optional +from databases import Database +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse -from backend.services.partner_service import PartnerService +from backend.db import get_db from backend.exceptions import BadRequest - -# Replaceable by another service which implements the method: -# fetch_partner_stats(id_inside_service, from_date, to_date) -> PartnerStatsDTO from backend.services.mapswipe_service import MapswipeService +from backend.services.partner_service import PartnerService MAPSWIPE_GROUP_EMPTY_SUBCODE = "EMPTY_MAPSWIPE_GROUP" MAPSWIPE_GROUP_EMPTY_MESSAGE = "Mapswipe group is not set for this partner." @@ -19,156 +18,178 @@ def is_valid_group_id(group_id: Optional[str]) -> bool: return group_id is not None and len(group_id) > 0 -class FilteredPartnerStatisticsAPI(Resource): - def get(self, permalink: str): - """ - Get partner statistics by id and time range - --- - tags: - - partners - produces: - - application/json - parameters: - - in: query - name: fromDate - type: string - description: Fetch partner statistics from date as yyyy-mm-dd - example: "2024-01-01" - - in: query - name: toDate - type: string - example: "2024-09-01" - description: Fetch partner statistics to date as yyyy-mm-dd - - name: partner_id - in: path - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - mapswipe = MapswipeService() - from_date = request.args.get("fromDate") - to_date = request.args.get("toDate") - - if from_date is None: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="fromDate is missing", - from_date=from_date, - to_date=to_date, - ) - - if to_date is None: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="toDate is missing", - from_date=from_date, - to_date=to_date, - ) - - if from_date > to_date: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="fromDate should be less than toDate", - from_date=from_date, - to_date=to_date, - ) - - partner = PartnerService.get_partner_by_permalink(permalink) - - if not is_valid_group_id(partner.mapswipe_group_id): - raise BadRequest( - sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, - message=MAPSWIPE_GROUP_EMPTY_MESSAGE, - ) - - return ( - mapswipe.fetch_filtered_partner_stats( - partner.id, partner.mapswipe_group_id, from_date, to_date - ).to_primitive(), - 200, +router = APIRouter( + prefix="/partners", + tags=["partners"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/{permalink:str}/filtered-statistics/") +async def get_filtered_statistics( + permalink: str, + from_date: str | None = Query( + default=None, + alias="fromDate", + description="Fetch partner statistics from date as yyyy-mm-dd", + example="2024-01-01", + ), + to_date: str | None = Query( + default=None, + alias="toDate", + description="Fetch partner statistics to date as yyyy-mm-dd", + example="2024-09-01", + ), + db: Database = Depends(get_db), +): + """ + Get partner statistics by id and time range + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: fromDate + type: string + description: Fetch partner statistics from date as yyyy-mm-dd + example: "2024-01-01" + - in: query + name: toDate + type: string + example: "2024-09-01" + description: Fetch partner statistics to date as yyyy-mm-dd + - name: partner_id + in: path + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + + if from_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if to_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="toDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if from_date > to_date: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate should be less than toDate", + from_date=from_date, + to_date=to_date, ) + partner = await PartnerService.get_partner_by_permalink(permalink, db) -class GroupPartnerStatisticsAPI(Resource): - def get(self, permalink: str): - """ - Get partner statistics by id and broken down by each contributor. - This API is paginated with limit and offset query parameters. - --- - tags: - - partners - produces: - - application/json - parameters: - - in: query - name: limit - description: The number of partner members to fetch - type: integer - example: 10 - - in: query - name: offset - description: The starting index from which to fetch partner members - type: integer - example: 0 - - in: query - name: downloadAsCSV - description: Download users in this group as CSV - type: boolean - example: false - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - - mapswipe = MapswipeService() - partner = PartnerService.get_partner_by_permalink(permalink) - - if not is_valid_group_id(partner.mapswipe_group_id): - raise BadRequest( - sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, - message=MAPSWIPE_GROUP_EMPTY_MESSAGE, - ) - - limit = int(request.args.get("limit", 10)) - offset = int(request.args.get("offset", 0)) - download_as_csv = bool(request.args.get("downloadAsCSV", "false") == "true") - - group_dto = mapswipe.fetch_grouped_partner_stats( - partner.id, - partner.mapswipe_group_id, - limit, - offset, - download_as_csv, + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, ) - if download_as_csv: - return send_file( - io.BytesIO(group_dto.to_csv().encode()), - mimetype="text/csv", - as_attachment=True, - download_name="partner_members.csv", - ) + mapswipe = MapswipeService() + return mapswipe.fetch_filtered_partner_stats( + partner.id, partner.mapswipe_group_id, from_date, to_date + ) + + +@router.get("/{permalink:str}/general-statistics/") +async def get_general_statistics( + permalink: str, + limit: int = Query(10, description="The number of partner members to fetch"), + offset: int = Query( + 0, description="The starting index from which to fetch partner members" + ), + download_as_csv: bool = Query( + False, alias="downloadAsCSV", description="Download users in this group as CSV" + ), + db: Database = Depends(get_db), +): + """ + Get partner statistics by id and broken down by each contributor. + This API is paginated with limit and offset query parameters. + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: limit + description: The number of partner members to fetch + type: integer + example: 10 + - in: query + name: offset + description: The starting index from which to fetch partner members + type: integer + example: 0 + - in: query + name: downloadAsCSV + description: Download users in this group as CSV + type: boolean + example: false + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + partner = await PartnerService.get_partner_by_permalink(permalink, db) + + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, + ) + + mapswipe = MapswipeService() + group_dto = mapswipe.fetch_grouped_partner_stats( + partner.id, + partner.mapswipe_group_id, + limit, + offset, + download_as_csv, + ) + + if download_as_csv: + csv_content = group_dto.to_csv() + return StreamingResponse( + content=io.StringIO(csv_content), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=partner_members.csv"}, + ) - return group_dto.to_primitive(), 200 + return group_dto diff --git a/backend/api/projects/actions.py b/backend/api/projects/actions.py index 23bcb73e6f..ffde268326 100644 --- a/backend/api/projects/actions.py +++ b/backend/api/projects/actions.py @@ -1,406 +1,467 @@ -import threading - -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, BackgroundTasks, Body, Depends, Request +from fastapi.responses import JSONResponse +from loguru import logger +from shapely import GEOSException +from shapely.errors import TopologicalError -from backend.models.dtos.message_dto import MessageDTO +from backend.db import get_db from backend.models.dtos.grid_dto import GridDTO -from backend.services.project_service import ProjectService +from backend.models.dtos.message_dto import MessageDTO +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.utils import InvalidGeoJson +from backend.services.grid.grid_service import GridService +from backend.services.interests_service import InterestService +from backend.services.messaging.message_service import MessageService from backend.services.project_admin_service import ( ProjectAdminService, ProjectAdminServiceError, ) -from backend.services.grid.grid_service import GridService -from backend.services.messaging.message_service import MessageService -from backend.services.users.authentication_service import token_auth, tm -from backend.services.interests_service import InterestService -from backend.models.postgis.utils import InvalidGeoJson +from backend.services.project_service import ProjectService +from backend.services.users.authentication_service import login_required -from shapely import GEOSException -from shapely.errors import TopologicalError +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsActionsTransferAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Transfers a project to a new user - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: username of the new owner - schema: - properties: - username: - type: string - responses: - 200: - description: Project ownership transferred successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - username = request.get_json()["username"] - except Exception: - return {"Error": "Username not provided", "SubCode": "InvalidData"}, 400 - try: - authenticated_user_id = token_auth.current_user() - ProjectAdminService.transfer_project_to( - project_id, authenticated_user_id, username - ) - return {"Success": "Project Transferred"}, 200 - except (ValueError, ProjectAdminServiceError) as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/actions/transfer-ownership/") +async def transfer_ownership( + request: Request, + background_tasks: BackgroundTasks, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + data: dict = Body(...), +): + """ + Transfers a project to a new user + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: username of the new owner + schema: + properties: + username: + type: string + responses: + 200: + description: Project ownership transferred successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + username = data["username"] + except Exception: + return JSONResponse( + content={"Error": "Username not provided", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectAdminService.transfer_project_to( + project_id, user.id, username, db, background_tasks + ) + return JSONResponse(content={"Success": "Project Transferred"}, status_code=200) + except (ValueError, ProjectAdminServiceError) as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class ProjectsActionsMessageContributorsAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Send message to all contributors of a project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating message - schema: - properties: - subject: - type: string - default: Thanks - required: true - message: - type: string - default: Thanks for your contribution - required: true - responses: - 200: - description: Message sent successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - message_dto = MessageDTO(request.get_json()) - message_dto.from_user_id = authenticated_user_id - message_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { - "Error": "Unable to send message to mappers", +@router.post("/{project_id}/actions/message-contributors/") +async def message_contributors( + request: Request, + background_tasks: BackgroundTasks, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Send message to all contributors of a project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating message + schema: + properties: + subject: + type: string + default: Thanks + required: true + message: + type: string + default: Thanks for your contribution + required: true + responses: + 200: + description: Message sent successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + request_json = await request.json() + request_json["from_user_id"] = user.id + message_dto = MessageDTO(**request_json) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ + "Error": "Unable to send message to contributors", "SubCode": "InvalidData", - }, 400 - - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - return { + }, + status_code=400, + ) + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - threading.Thread( - target=MessageService.send_message_to_all_contributors, - args=(project_id, message_dto), - ).start() - return {"Success": "Messages started"}, 200 + }, + status_code=403, + ) + try: + background_tasks.add_task( + MessageService.send_message_to_all_contributors, + project_id, + message_dto, + ) + return JSONResponse(content={"Success": "Messages started"}, status_code=200) + except Exception as e: + logger.error(f"Error starting background task: {str(e)}") + return JSONResponse( + content={"Error": "Failed to send messages"}, status_code=500 + ) -class ProjectsActionsFeatureAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Set a project as featured - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Featured projects - 400: - description: Bad request - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/actions/feature/") +async def feature( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Set a project as featured + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Featured projects + 400: + description: Bad request + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - try: - ProjectService.set_project_as_featured(project_id) - return {"Success": True}, 200 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + try: + await ProjectService.set_project_as_featured(project_id, db) + return JSONResponse(content={"Success": True}, status_code=200) + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class ProjectsActionsUnFeatureAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Unset a project as featured - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project is no longer featured - 400: - description: Bad request - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/actions/remove-feature/") +async def remove_feature( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Unset a project as featured + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project is no longer featured + 400: + description: Bad request + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - try: - ProjectService.unset_project_as_featured(project_id) - return {"Success": True}, 200 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + try: + await ProjectService.unset_project_as_featured(project_id, db) + return JSONResponse(content={"Success": True}, status_code=200) + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class ProjectsActionsSetInterestsAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Creates a relationship between project and interests - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating/updating project and interests relationships - schema: - properties: - interests: - type: array - items: - type: integer - responses: - 200: - description: New project interest relationship created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/actions/set-interests/") +async def set_interests( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + data: dict = Body(...), +): + """ + Creates a relationship between project and interests + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating/updating project and interests relationships + schema: + properties: + interests: + type: array + items: + type: integer + responses: + 200: + description: New project interest relationship created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - - data = request.get_json() - project_interests = InterestService.create_or_update_project_interests( - project_id, data["interests"] + }, + status_code=403, ) - return project_interests.to_primitive(), 200 + project_interests = await InterestService.create_or_update_project_interests( + project_id, data["interests"], db + ) + return project_interests.model_dump(by_alias=True) -class ProjectActionsIntersectingTilesAPI(Resource): - @tm.pm_only() - @token_auth.login_required - def post(self): - """ - Gets the tiles intersecting the aoi - --- - tags: - - grid - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object containing aoi and tasks and bool flag for controlling clip grid to aoi - schema: - properties: - clipToAoi: - type: boolean - default: true - areaOfInterest: - schema: - properties: - type: - type: string - default: FeatureCollection - features: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonFeature" - grid: - schema: - properties: - type: - type: string - default: FeatureCollection - features: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonFeature" - responses: - 200: - description: Intersecting tasks found successfully - 400: - description: Client Error - Invalid Request - 500: - description: Internal Server Error - """ - try: - grid_dto = GridDTO(request.get_json()) - grid_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - try: - grid = GridService.trim_grid_to_aoi(grid_dto) - return grid, 200 - except InvalidGeoJson as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - except TopologicalError: - return { - "error": "Invalid geometry. Polygon is self intersecting", +@router.post("/actions/intersecting-tiles/") +async def intersecting_tiles( + request: Request, + user: AuthUserDTO = Depends(login_required), + grid_dto: GridDTO = Body(...), +): + """ + Gets the tiles intersecting the aoi + --- + tags: + - grid + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object containing aoi and tasks and bool flag for controlling clip grid to aoi + schema: + properties: + clipToAoi: + type: boolean + default: true + areaOfInterest: + schema: + properties: + type: + type: string + default: FeatureCollection + features: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonFeature" + grid: + schema: + properties: + type: + type: string + default: FeatureCollection + features: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonFeature" + responses: + 200: + description: Intersecting tasks found successfully + 400: + description: Client Error - Invalid Request + 500: + description: Internal Server Error + """ + try: + grid = GridService.trim_grid_to_aoi(grid_dto) + return JSONResponse(content=grid, status_code=200) + except InvalidGeoJson as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + except TopologicalError: + return JSONResponse( + content={ + "Error": "Invalid geometry. Polygon is self intersecting", "SubCode": "SelfIntersectingAOI", - }, 400 - except GEOSException as wrapped: - if ( - isinstance(wrapped.args[0], str) - and "Self-intersection" in wrapped.args[0] - ): - return { + }, + status_code=400, + ) + except GEOSException as wrapped: + if isinstance(wrapped.args[0], str) and "Self-intersection" in wrapped.args[0]: + return JSONResponse( + content={ "error": "Invalid geometry. Polygon is self intersecting", "SubCode": "SelfIntersectingAOI", - }, 400 - return {"error": str(wrapped), "SubCode": "InternalServerError"} + }, + status_code=400, + ) + return JSONResponse( + content={"error": str(wrapped), "SubCode": "InternalServerError"} + ) diff --git a/backend/api/projects/activities.py b/backend/api/projects/activities.py index 9d829dfd76..a122974802 100644 --- a/backend/api/projects/activities.py +++ b/backend/api/projects/activities.py @@ -1,66 +1,79 @@ -from flask_restful import Resource, request +from databases import Database +from fastapi import APIRouter, Depends, Request, Query -from backend.services.stats_service import StatsService +from backend.db import get_db from backend.services.project_service import ProjectService +from backend.services.stats_service import StatsService + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsActivitiesAPI(Resource): - def get(self, project_id): - """ - Get all user activity on a project - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: query - name: page - description: Page of results user requested - type: integer - responses: - 200: - description: Project activity - 404: - description: No activity - 500: - description: Internal Server Error - """ - ProjectService.exists(project_id) - page = int(request.args.get("page")) if request.args.get("page") else 1 - activity = StatsService.get_latest_activity(project_id, page) - return activity.to_primitive(), 200 +@router.get("/{project_id}/activities/") +async def get_activities( + project_id: int, + page: int = Query(1, description="Page of results user requested", ge=1), + db: Database = Depends(get_db), +): + """ + Get all user activity on a project + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: query + name: page + description: Page of results user requested + type: integer + responses: + 200: + description: Project activity + 404: + description: No activity + 500: + description: Internal Server Error + """ + await ProjectService.exists(project_id, db) + activity = await StatsService.get_latest_activity(project_id, page, db) + return activity -class ProjectsLastActivitiesAPI(Resource): - def get(self, project_id): - """ - Get latest user activity on all of project task - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - required: true - type: integer - default: 1 - responses: - 200: - description: Project activity - 404: - description: No activity - 500: - description: Internal Server Error - """ - ProjectService.exists(project_id) - activity = StatsService.get_last_activity(project_id) - return activity.to_primitive(), 200 +@router.get("/{project_id}/activities/latest/") +async def get_latest_activities( + request: Request, project_id: int, db: Database = Depends(get_db) +): + """ + Get latest user activity on all of project task + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + required: true + type: integer + default: 1 + responses: + 200: + description: Project activity + 404: + description: No activity + 500: + description: Internal Server Error + """ + await ProjectService.exists(project_id, db) + activity = await StatsService.get_last_activity(project_id, db) + return activity diff --git a/backend/api/projects/campaigns.py b/backend/api/projects/campaigns.py index 6d29087970..f5b8d5a57c 100644 --- a/backend/api/projects/campaigns.py +++ b/backend/api/projects/campaigns.py @@ -1,154 +1,189 @@ -from flask_restful import Resource, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from backend.db import get_db from backend.models.dtos.campaign_dto import CampaignProjectDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.campaign_service import CampaignService from backend.services.project_admin_service import ProjectAdminService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsCampaignsAPI(Resource): - @token_auth.login_required - def post(self, project_id, campaign_id): - """ - Assign a campaign for a project - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - name: campaign_id - in: path - description: Unique campaign ID - required: true - type: integer - default: 1 - responses: - 201: - description: Campaign assigned successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - return { - "Error": "User is not a manager of the project", - "SubCode": "UserPermissionError", - }, 403 - try: - campaign_project_dto = CampaignProjectDTO() - campaign_project_dto.campaign_id = campaign_id - campaign_project_dto.project_id = project_id - campaign_project_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - CampaignService.create_campaign_project(campaign_project_dto) - message = ( - "campaign with id {} assigned successfully for project with id {}".format( - campaign_id, project_id - ) +@router.post("/{project_id}/campaigns/{campaign_id}/") +async def create_project_campaign( + project_id: int, + campaign_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Assign a campaign for a project + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - name: campaign_id + in: path + description: Unique campaign ID + required: true + type: integer + default: 1 + responses: + 201: + description: Campaign assigned successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + return { + "Error": "User is not a manager of the project", + "SubCode": "UserPermissionError", + }, 403 + + # Check if the project is already assigned to the campaign + query = """ + SELECT COUNT(*) + FROM campaign_projects + WHERE project_id = :project_id AND campaign_id = :campaign_id + """ + result = await db.fetch_val( + query, values={"project_id": project_id, "campaign_id": campaign_id} + ) + + if result > 0: + return JSONResponse( + content={ + "Error": "Project is already assigned to this campaign", + "SubCode": "CampaignAssignmentError", + }, + status_code=400, ) - return ({"Success": message}, 200) - def get(self, project_id): - """ - Gets all campaigns for a project - --- - tags: - - campaigns - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Campaign list returned successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - campaigns = CampaignService.get_project_campaigns_as_dto(project_id) - return campaigns.to_primitive(), 200 + campaign_project_dto = CampaignProjectDTO( + project_id=project_id, campaign_id=campaign_id + ) + + await CampaignService.create_campaign_project(campaign_project_dto, db) + message = "campaign with id {} assigned successfully for project with id {}".format( + campaign_id, project_id + ) + return JSONResponse(content={"Success": message}, status_code=200) - @token_auth.login_required - def delete(self, project_id, campaign_id): - """ - Delete a campaign for a project - --- - tags: - - campaigns - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - name: campaign_id - in: path - description: Unique campaign ID - required: true - type: integer - default: 1 - responses: - 200: - description: Campaign assigned successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - return { + +@router.get("/{project_id}/campaigns/") +async def get_project_campaigns(project_id: int, db: Database = Depends(get_db)): + """ + Gets all campaigns for a project + --- + tags: + - campaigns + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Campaign list returned successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + campaigns = await CampaignService.get_project_campaigns_as_dto(project_id, db) + return campaigns + + +@router.delete("/{project_id}/campaigns/{campaign_id}/") +async def delete_project_campaign( + project_id: int, + campaign_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Delete a campaign for a project + --- + tags: + - campaigns + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - name: campaign_id + in: path + description: Unique campaign ID + required: true + type: integer + default: 1 + responses: + 200: + description: Campaign assigned successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - CampaignService.delete_project_campaign(project_id, campaign_id) - return {"Success": "Campaigns Deleted"}, 200 + await CampaignService.delete_project_campaign(project_id, campaign_id, db) + return JSONResponse(content={"Success": "Campaigns Deleted"}, status_code=200) diff --git a/backend/api/projects/contributions.py b/backend/api/projects/contributions.py index 18bfb59146..39cb56ef6b 100644 --- a/backend/api/projects/contributions.py +++ b/backend/api/projects/contributions.py @@ -1,61 +1,70 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends +from backend.db import get_db +from backend.models.postgis.project import Project from backend.services.project_service import ProjectService from backend.services.stats_service import StatsService +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsContributionsAPI(Resource): - def get(self, project_id): - """ - Get all user contributions on a project - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: User contributions - 404: - description: No contributions - 500: - description: Internal Server Error - """ - ProjectService.exists(project_id) - contributions = StatsService.get_user_contributions(project_id) - return contributions.to_primitive(), 200 +@router.get("/{project_id}/contributions/") +async def get_project_contributions(project_id: int, db: Database = Depends(get_db)): + """ + Get all user contributions on a project + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: User contributions + 404: + description: No contributions + 500: + description: Internal Server Error + """ + await Project.exists(project_id, db) + contributions = await StatsService.get_user_contributions(project_id, db) + return contributions -class ProjectsContributionsQueriesDayAPI(Resource): - def get(self, project_id): - """ - Get contributions by day for a project - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project contributions by day - 404: - description: Not found - 500: - description: Internal Server Error - """ - contribs = ProjectService.get_contribs_by_day(project_id) - return contribs.to_primitive(), 200 + +@router.get("/{project_id}/contributions/queries/day/") +async def get_contributions_by_day(project_id: int, db: Database = Depends(get_db)): + """ + Get contributions by day for a project + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project contributions by day + 404: + description: Not found + 500: + description: Internal Server Error + """ + contribs = await ProjectService.get_contribs_by_day(project_id, db) + return contribs diff --git a/backend/api/projects/favorites.py b/backend/api/projects/favorites.py index 5dc9ea9009..5edd6f290a 100644 --- a/backend/api/projects/favorites.py +++ b/backend/api/projects/favorites.py @@ -1,122 +1,149 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse -from backend.models.dtos.project_dto import ProjectFavoriteDTO +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.project_service import ProjectService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsFavoritesAPI(Resource): - @token_auth.login_required - def get(self, project_id: int): - """ - Validate that project is favorited - --- - tags: - - favorites - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - responses: - 200: - description: Project favorite - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - user_id = token_auth.current_user() - favorited = ProjectService.is_favorited(project_id, user_id) - if favorited is True: - return {"favorited": True}, 200 - return {"favorited": False}, 200 +@router.get("/{project_id}/favorite/") +async def get_favorite( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Validate that project is favorited + --- + tags: + - favorites + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + responses: + 200: + description: Project favorite + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) - @token_auth.login_required - def post(self, project_id: int): - """ - Set a project as favorite - --- - tags: - - favorites - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - responses: - 200: - description: New favorite created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - favorite_dto = ProjectFavoriteDTO() - favorite_dto.project_id = project_id - favorite_dto.user_id = authenticated_user_id + favorited = await ProjectService.is_favorited(project_id, user_id, db) + if favorited is True: + return JSONResponse(content={"favorited": True}, status_code=200) + return JSONResponse(content={"favorited": False}, status_code=200) - ProjectService.favorite(project_id, authenticated_user_id) - return {"project_id": project_id}, 200 - @token_auth.login_required - def delete(self, project_id: int): - """ - Unsets a project as favorite - --- - tags: - - favorites - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - responses: - 200: - description: New favorite created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - ProjectService.unfavorite(project_id, token_auth.current_user()) - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 +@router.post("/{project_id}/favorite/") +async def make_favorite( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Set a project as favorite + --- + tags: + - favorites + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + responses: + 200: + description: New favorite created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ - return {"project_id": project_id}, 200 + await ProjectService.favorite(project_id, user.id, db) + return JSONResponse(content={"project_id": project_id}, status_code=201) + + +@router.delete("/{project_id}/favorite/") +async def remove_favorite( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Unsets a project as favorite + --- + tags: + - favorites + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + responses: + 200: + description: New favorite created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + await ProjectService.unfavorite(project_id, user.id, db) + except ValueError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + return JSONResponse(content={"project_id": project_id}, status_code=200) diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 6a5152996d..1bca1c7ab0 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -1,293 +1,342 @@ -from flask_restful import Resource, request -from backend.services.project_partnership_service import ProjectPartnershipService -from backend.services.users.authentication_service import token_auth -from backend.services.project_admin_service import ProjectAdminService +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from backend.db import get_db from backend.models.dtos.project_partner_dto import ( ProjectPartnershipDTO, ProjectPartnershipUpdateDTO, ) +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.utils import timestamp +from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_partnership_service import ProjectPartnershipService +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -def check_if_manager(partnership_dto: ProjectPartnershipDTO): - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user(), partnership_dto.project_id +@staticmethod +async def check_if_manager( + partnership_dto: ProjectPartnershipDTO, user_id: int, db: Database +): + if not await ProjectAdminService.is_user_action_permitted_on_project( + user_id, partnership_dto.project_id, db ): - return { - "Error": "User is not a manager of the project", - "SubCode": "UserPermissionError", - }, 401 + return JSONResponse( + content={ + "Error": "User is not a manager of the project", + "SubCode": "UserPermissionError", + }, + status_code=401, + ) -class ProjectPartnershipsRestApi(Resource): - @staticmethod - def get(partnership_id: int): - """ - Retrieves a Partnership by id - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - responses: - 200: - description: Partnership found - 404: - description: Partnership not found - 500: - description: Internal Server Error - """ +@router.get("/partnerships/{partnership_id}/") +async def retrieve_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), +): + """ + Retrieves a Partnership by id + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 200: + description: Partnership found + 404: + description: Partnership not found + 500: + description: Internal Server Error + """ - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) - return partnership_dto.to_primitive(), 200 + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) + return partnership_dto - @token_auth.login_required - def post(self): - """Assign a partner to a project - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating a partnership - schema: - properties: - projectId: - required: true - type: int - description: Unique project ID - default: 1 - partnerId: - required: true - type: int - description: Unique partner ID - default: 1 - startedOn: - type: date - description: The timestamp when the partner is added to a project. Defaults to current time. - default: "2017-04-11T12:38:49" - endedOn: - type: date - description: The timestamp when the partner ended their work on a project. - default: "2018-04-11T12:38:49" - responses: - 201: - description: Partner project association created - 400: - description: Ivalid dates or started_on was after ended_on - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_dto = ProjectPartnershipDTO(request.get_json()) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error +@router.post("/partnerships/") +async def create_partnership( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Assign a partner to a project + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + projectId: + required: true + type: int + description: Unique project ID + default: 1 + partnerId: + required: true + type: int + description: Unique partner ID + default: 1 + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + request_data = await request.json() - if partnership_dto.started_on is None: - partnership_dto.started_on = timestamp() + partnership_dto = ProjectPartnershipDTO(**request_data) + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error - partnership_dto = ProjectPartnershipDTO(request.get_json()) - partnership_id = ProjectPartnershipService.create_partnership( + if partnership_dto.started_on is None: + partnership_dto.started_on = timestamp() + + async with db.transaction(): + partnership_id = await ProjectPartnershipService.create_partnership( + db, partnership_dto.project_id, partnership_dto.partner_id, partnership_dto.started_on, partnership_dto.ended_on, ) - return ( - { + return ( + JSONResponse( + content={ "Success": "Partner {} assigned to project {}".format( partnership_dto.partner_id, partnership_dto.project_id ), "partnershipId": partnership_id, }, - 201, - ) + status_code=201, + ), + ) - @staticmethod - @token_auth.login_required - def patch(partnership_id: int): - """Update the time range for a partner project link - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating a partnership - schema: - properties: - startedOn: - type: date - description: The timestamp when the partner is added to a project. Defaults to current time. - default: "2017-04-11T12:38:49" - endedOn: - type: date - description: The timestamp when the partner ended their work on a project. - default: "2018-04-11T12:38:49" - responses: - 201: - description: Partner project association created - 400: - description: Ivalid dates or started_on was after ended_on - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_updates = ProjectPartnershipUpdateDTO(request.get_json()) - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error +@router.patch("/partnerships/{partnership_id}/") +async def patch_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Update the time range for a partner project link + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + request_data = await request.json() + partnership_updates = ProjectPartnershipUpdateDTO(**request_data) + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) - partnership = ProjectPartnershipService.update_partnership_time_range( + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error + + async with db.transaction(): + partnership = await ProjectPartnershipService.update_partnership_time_range( + db, partnership_id, partnership_updates.started_on, partnership_updates.ended_on, ) - - return ( - { + return ( + JSONResponse( + content={ "Success": "Updated time range. startedOn: {}, endedOn: {}".format( partnership.started_on, partnership.ended_on ), "startedOn": f"{partnership.started_on}", "endedOn": f"{partnership.ended_on}", }, - 200, - ) - - @staticmethod - @token_auth.login_required - def delete(partnership_id: int): - """Deletes a link between a project and a partner - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - responses: - 201: - description: Partner project association created - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) + status_code=200, + ), + ) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error - ProjectPartnershipService.delete_partnership(partnership_id) - return ( - { +@router.delete("/partnerships/{partnership_id}/") +async def delete_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Deletes a link between a project and a partner + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 201: + description: Partner project association created + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error + async with db.transaction(): + await ProjectPartnershipService.delete_partnership(partnership_id, db) + return ( + JSONResponse( + content={ "Success": "Partnership ID {} deleted".format(partnership_id), }, - 200, - ) + status_code=200, + ), + ) -class PartnersByProjectAPI(Resource): - @staticmethod - def get(project_id: int): - """ - Retrieves the list of partners associated with a project - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: List (possibly empty) of partners associated with this project_id - 500: - description: Internal Server Error - """ - partnerships = ProjectPartnershipService.get_partnerships_by_project(project_id) - return {"partnerships": partnerships}, 200 +@router.get("/{project_id}/partners/") +async def get_partners( + request: Request, + project_id: int, + db: Database = Depends(get_db), +): + """ + Retrieves the list of partners associated with a project + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: List (possibly empty) of partners associated with this project_id + 500: + description: Internal Server Error + """ + partnerships = await ProjectPartnershipService.get_partnerships_by_project( + project_id, db + ) + return {"partnerships": partnerships} diff --git a/backend/api/projects/resources.py b/backend/api/projects/resources.py index 334bcd1520..fb70c6f78f 100644 --- a/backend/api/projects/resources.py +++ b/backend/api/projects/resources.py @@ -1,161 +1,200 @@ -import geojson import io -from flask import send_file -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError from distutils.util import strtobool +from typing import Optional + +import geojson +from databases import Database +from fastapi import APIRouter, Depends, Request, Query, Path +from fastapi.responses import JSONResponse, StreamingResponse, Response +from loguru import logger + +from backend.db import get_db from backend.models.dtos.project_dto import ( DraftProjectDTO, ProjectDTO, - ProjectSearchDTO, ProjectSearchBBoxDTO, + ProjectSearchDTO, ) +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.statuses import UserRole +from backend.services.organisation_service import OrganisationService +from backend.services.project_admin_service import ( + InvalidData, + InvalidGeoJson, + ProjectAdminService, + ProjectAdminServiceError, +) from backend.services.project_search_service import ( + BBoxTooBigError, ProjectSearchService, ProjectSearchServiceError, - BBoxTooBigError, ) from backend.services.project_service import ( + NotFound, ProjectService, ProjectServiceError, - NotFound, +) +from backend.services.recommendation_service import ProjectRecommendationService +from backend.services.users.authentication_service import ( + login_required, + login_required_optional, ) from backend.services.users.user_service import UserService -from backend.services.organisation_service import OrganisationService -from backend.services.users.authentication_service import token_auth -from backend.services.project_admin_service import ( - ProjectAdminService, - ProjectAdminServiceError, - InvalidGeoJson, - InvalidData, + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, ) -from backend.services.recommendation_service import ProjectRecommendationService -class ProjectsRestAPI(Resource): - @token_auth.login_required(optional=True) - def get(self, project_id): - """ - Get a specified project including it's area - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: false - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: query - name: as_file - type: boolean - description: Set to true if file download is preferred - default: False - - in: query - name: abbreviated - type: boolean - description: Set to true if only state information is desired - default: False - responses: - 200: - description: Project found - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - as_file = bool( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else False - ) - abbreviated = bool( - strtobool(request.args.get("abbreviated")) - if request.args.get("abbreviated") - else False - ) - project_dto = ProjectService.get_project_dto_for_mapper( - project_id, - authenticated_user_id, - request.environ.get("HTTP_ACCEPT_LANGUAGE"), - abbreviated, - ) +@router.get("/{project_id}/") +async def get_project( + request: Request, + project_id: int, + as_file: str = "False", + abbreviated: str = "False", + db: Database = Depends(get_db), + user: Optional[AuthUserDTO] = Depends(login_required_optional), +): + """ + Get a specified project including it's area + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: false + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: query + name: as_file + type: boolean + description: Set to true if file download is preferred + default: False + - in: query + name: abbreviated + type: boolean + description: Set to true if only state information is desired + default: False + responses: + 200: + description: Project found + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + user_id = user.id if user else None + as_file = bool(strtobool(as_file) if as_file else False) + abbreviated = bool(strtobool(abbreviated) if abbreviated else False) + project_dto = await ProjectService.get_project_dto_for_mapper( + project_id, + user_id, + db, + request.headers.get("accept-language"), + abbreviated, + ) + if project_dto: + if as_file: + json_str = project_dto.json() + buffer = io.BytesIO(json_str.encode("utf-8")) + return StreamingResponse( + buffer, + media_type="application/json", + headers={ + "Content-Disposition": "attachment; filename=project.json" + }, + ) + return project_dto - if project_dto: - project_dto = project_dto.to_primitive() - if as_file: - return send_file( - io.BytesIO(geojson.dumps(project_dto).encode("utf-8")), - mimetype="application/json", - as_attachment=True, - download_name=f"project_{str(project_id)}.json", - ) - - return project_dto, 200 - else: - return { + else: + return JSONResponse( + content={ "Error": "User not permitted: Private Project", "SubCode": "PrivateProject", - }, 403 - except ProjectServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - finally: - # this will try to unlock tasks that have been locked too long - try: - ProjectService.auto_unlock_tasks(project_id) - except Exception as e: - current_app.logger.critical(str(e)) - - @token_auth.login_required - def post(self): - """ - Creates a tasking-manager project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating draft project - schema: - properties: - cloneFromProjectId: - type: int - default: 1 - description: Specify this value if you want to clone a project, otherwise avoid information - projectName: - type: string - default: HOT Project - areaOfInterest: + }, + status_code=403, + ) + + except ProjectServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + finally: + # this will try to unlock tasks that have been locked too long + try: + await ProjectService.auto_unlock_tasks(project_id, db) + except Exception as e: + logger.critical(str(e)) + + +@router.post("/") +async def create_project( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + draft_project_dto: DraftProjectDTO = None, +): + """ + Creates a tasking-manager project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating draft project + schema: + properties: + cloneFromProjectId: + type: int + default: 1 + description: Specify this value if you want to clone a project, otherwise avoid information + projectName: + type: string + default: HOT Project + areaOfInterest: + schema: + properties: + type: + type: string + default: FeatureCollection + features: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonFeature" + tasks: schema: properties: type: @@ -166,1125 +205,1288 @@ def post(self): items: schema: $ref: "#/definitions/GeoJsonFeature" - tasks: - schema: - properties: - type: - type: string - default: FeatureCollection - features: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonFeature" - arbitraryTasks: - type: boolean - default: false - responses: - 201: - description: Draft project created successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - draft_project_dto = DraftProjectDTO(request.get_json()) - draft_project_dto.user_id = token_auth.current_user() - draft_project_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": "Unable to create project", "SubCode": "InvalidData"}, 400 + arbitraryTasks: + type: boolean + default: false + responses: + 201: + description: Draft project created successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + draft_project_dto.user_id = user.id + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to create project", "SubCode": "InvalidData"}, + status_code=400, + ) - try: - draft_project_id = ProjectAdminService.create_draft_project( - draft_project_dto + try: + async with db.transaction(): + draft_project_id = await ProjectAdminService.create_draft_project( + draft_project_dto, db ) - return {"projectId": draft_project_id}, 201 - except ProjectAdminServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - except (InvalidGeoJson, InvalidData) as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - - @token_auth.login_required - def head(self, project_id): - """ - Retrieves a Tasking-Manager project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project found - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user(), project_id + return JSONResponse( + content={"projectId": draft_project_id}, status_code=201 ) - except ValueError: - return { + except ProjectAdminServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + + except (InvalidGeoJson, InvalidData) as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + + except Exception as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + + +def head(request: Request, project_id): + """ + Retrieves a Tasking-Manager project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project found + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + ProjectAdminService.is_user_action_permitted_on_project( + request.user.display_name, project_id + ) + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - - project_dto = ProjectAdminService.get_project_dto_for_admin(project_id) - return project_dto.to_primitive(), 200 - - @token_auth.login_required - def patch(self, project_id): - """ - Updates a Tasking-Manager project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating an existing project - schema: - properties: - projectStatus: - type: string - default: DRAFT - projectPriority: - type: string - default: MEDIUM - defaultLocale: - type: string - default: en - difficulty: - type: string - default: EASY - validation_permission: - type: string - default: ANY - mapping_permission: - type: string - default: ANY - private: - type: boolean - default: false - changesetComment: + }, + status_code=403, + ) + + project_dto = ProjectAdminService.get_project_dto_for_admin(project_id) + return project_dto.model_dump(by_alias=True), 200 + + +@router.patch("/{project_id}/") +async def patch_project( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + project_dto: dict = None, +): + """ + Updates a Tasking-Manager project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating an existing project + schema: + properties: + projectStatus: + type: string + default: DRAFT + projectPriority: + type: string + default: MEDIUM + defaultLocale: + type: string + default: en + difficulty: + type: string + default: EASY + validation_permission: + type: string + default: ANY + mapping_permission: + type: string + default: ANY + private: + type: boolean + default: false + changesetComment: + type: string + default: hotosm-project-1 + dueDate: + type: date + default: "2017-04-11T12:38:49" + imagery: + type: string + default: http//www.bing.com/maps/ + josmPreset: + type: string + default: josm preset goes here + mappingTypes: + type: array + items: type: string - default: hotosm-project-1 - dueDate: - type: date - default: "2017-04-11T12:38:49" - imagery: + default: [BUILDINGS, ROADS] + mappingEditors: + type: array + items: type: string - default: http//www.bing.com/maps/ - josmPreset: + default: [ID, JOSM, POTLATCH_2, FIELD_PAPERS] + validationEditors: + type: array + items: type: string - default: josm preset goes here - mappingTypes: + default: [ID, JOSM, POTLATCH_2, FIELD_PAPERS] + campaign: + type: string + default: malaria + organisation: + type: integer + default: 1 + countryTag: type: array items: type: string - default: [BUILDINGS, ROADS] - mappingEditors: - type: array - items: - type: string - default: [ID, JOSM, POTLATCH_2, FIELD_PAPERS] - validationEditors: - type: array - items: - type: string - default: [ID, JOSM, POTLATCH_2, FIELD_PAPERS] - campaign: + default: [] + licenseId: + type: integer + default: 1 + description: Id of imagery license associated with the project + allowedUsernames: + type: array + items: type: string - default: malaria - organisation: - type: integer - default: 1 - countryTag: - type: array - items: - type: string - default: [] - licenseId: - type: integer - default: 1 - description: Id of imagery license associated with the project - allowedUsernames: - type: array - items: - type: string - default: ["Iain Hunter", LindaA1] - priorityAreas: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonPolygon" - projectInfoLocales: - type: array - items: - schema: - $ref: "#/definitions/ProjectInfo" - taskCreationMode: - type: integer - default: GRID - responses: - 200: - description: Project updated - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - return { + default: ["Iain Hunter", LindaA1] + priorityAreas: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonPolygon" + projectInfoLocales: + type: array + items: + schema: + $ref: "#/definitions/ProjectInfo" + taskCreationMode: + type: integer + default: GRID + responses: + 200: + description: Project updated + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - try: - project_dto = ProjectDTO(request.get_json()) - project_dto.project_id = project_id - project_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to update project", "SubCode": "InvalidData"}, 400 + }, + status_code=403, + ) + project_dto = ProjectDTO(**project_dto) + project_dto.project_id = project_id - try: - ProjectAdminService.update_project(project_dto, authenticated_user_id) - return {"Status": "Updated"}, 200 - except InvalidGeoJson as e: - return {"Invalid GeoJson": str(e)}, 400 - except ProjectAdminServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - - @token_auth.login_required - def delete(self, project_id): - """ - Deletes a Tasking-Manager project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project deleted - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { + try: + async with db.transaction(): + await ProjectAdminService.update_project(project_dto, user.id, db) + return JSONResponse(content={"Status": "Updated"}, status_code=200) + except InvalidGeoJson as e: + return JSONResponse(content={"Invalid GeoJson": str(e)}, status_code=400) + except ProjectAdminServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + + +@router.delete("/{project_id}/") +async def delete_project( + request: Request, + project_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Deletes a Tasking-Manager project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project deleted + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - - try: - ProjectAdminService.delete_project(project_id, authenticated_user_id) - return {"Success": "Project deleted"}, 200 - except ProjectAdminServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - - -class ProjectSearchBase(Resource): - @token_auth.login_required(optional=True) - def setup_search_dto(self) -> ProjectSearchDTO: - search_dto = ProjectSearchDTO() - search_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - search_dto.difficulty = request.args.get("difficulty") - search_dto.action = request.args.get("action") - search_dto.organisation_name = request.args.get("organisationName") - search_dto.organisation_id = request.args.get("organisationId") - search_dto.team_id = request.args.get("teamId") - search_dto.campaign = request.args.get("campaign") - search_dto.order_by = request.args.get("orderBy", "priority") - search_dto.country = request.args.get("country") - search_dto.order_by_type = request.args.get("orderByType", "ASC") - search_dto.page = ( - int(request.args.get("page")) if request.args.get("page") else 1 + }, + status_code=403, ) - search_dto.text_search = request.args.get("textSearch") - search_dto.omit_map_results = strtobool( - request.args.get("omitMapResults", "false") + try: + async with db.transaction(): + await ProjectAdminService.delete_project(project_id, user.id, db) + return JSONResponse(content={"Success": "Project deleted"}, status_code=200) + except ProjectAdminServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, ) - search_dto.last_updated_gte = request.args.get("lastUpdatedFrom") - search_dto.last_updated_lte = request.args.get("lastUpdatedTo") - search_dto.created_gte = request.args.get("createdFrom") - search_dto.created_lte = request.args.get("createdTo") - search_dto.partner_id = request.args.get("partnerId") - search_dto.partnership_from = request.args.get("partnershipFrom") - search_dto.partnership_to = request.args.get("partnershipTo") - search_dto.download_as_csv = request.args.get("downloadAsCSV") - - # See https://github.com/hotosm/tasking-manager/pull/922 for more info - try: - authenticated_user_id = token_auth.current_user() - if request.args.get("createdByMe") == "true": - search_dto.created_by = authenticated_user_id - if request.args.get("mappedByMe") == "true": - search_dto.mapped_by = authenticated_user_id - if request.args.get("favoritedByMe") == "true": - search_dto.favorited_by = authenticated_user_id +def setup_search_dto(request) -> ProjectSearchDTO: + search_dto = ProjectSearchDTO() + search_dto.preferred_locale = request.headers.get("accept-language") + search_dto.difficulty = request.query_params.get("difficulty") + search_dto.action = request.query_params.get("action") + search_dto.organisation_name = request.query_params.get("organisationName") + search_dto.organisation_id = request.query_params.get("organisationId") + search_dto.team_id = request.query_params.get("teamId") + search_dto.campaign = request.query_params.get("campaign") + search_dto.order_by = request.query_params.get("orderBy", "priority") + search_dto.country = request.query_params.get("country") + search_dto.order_by_type = request.query_params.get("orderByType", "ASC") + search_dto.page = ( + int(request.query_params.get("page")) if request.query_params.get("page") else 1 + ) + search_dto.text_search = request.query_params.get("textSearch") + search_dto.omit_map_results = strtobool( + request.query_params.get("omitMapResults", "false") + ) + search_dto.last_updated_gte = request.query_params.get("lastUpdatedFrom") + search_dto.last_updated_lte = request.query_params.get("lastUpdatedTo") + search_dto.created_gte = request.query_params.get("createdFrom") + search_dto.created_lte = request.query_params.get("createdTo") + search_dto.partner_id = request.query_params.get("partnerId") + search_dto.partnership_from = request.query_params.get("partnershipFrom") + search_dto.partnership_to = request.query_params.get("partnershipTo") + search_dto.download_as_csv = request.query_params.get("downloadAsCSV") - if request.args.get("managedByMe") == "true": - search_dto.managed_by = authenticated_user_id - if request.args.get("basedOnMyInterests") == "true": - search_dto.based_on_user_interests = authenticated_user_id + # See https://github.com/hotosm/tasking-manager/pull/922 for more info + try: + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) - except Exception: - pass + if request.query_params.get("createdByMe") == "true": + search_dto.created_by = authenticated_user_id - mapping_types_str = request.args.get("mappingTypes") - if mapping_types_str: - search_dto.mapping_types = map( - str, mapping_types_str.split(",") - ) # Extract list from string - search_dto.mapping_types_exact = strtobool( - request.args.get("mappingTypesExact", "false") + if request.query_params.get("mappedByMe") == "true": + search_dto.mapped_by = authenticated_user_id + + if request.query_params.get("favoritedByMe") == "true": + search_dto.favorited_by = authenticated_user_id + + if request.query_params.get("managedByMe") == "true": + search_dto.managed_by = authenticated_user_id + + if request.query_params.get("basedOnMyInterests") == "true": + search_dto.based_on_user_interests = authenticated_user_id + + except Exception: + pass + + mapping_types_str = request.query_params.get("mappingTypes") + if mapping_types_str: + search_dto.mapping_types = list( + map(str, mapping_types_str.split(",")) + ) # Extract list from string + search_dto.mapping_types_exact = strtobool( + request.query_params.get("mappingTypesExact", "false") + ) + project_statuses_str = request.query_params.get("projectStatuses") + if project_statuses_str: + search_dto.project_statuses = list(map(str, project_statuses_str.split(","))) + interests_str = request.query_params.get("interests") + if interests_str: + search_dto.interests = map(int, interests_str.split(",")) + + return search_dto + + +@router.get("/") +async def get_projects( + request: Request, + difficulty: Optional[str] = Query(None), + action: Optional[str] = Query(None), + organisation_name: Optional[str] = Query(None, alias="organisationName"), + organisation_id: Optional[int] = Query(None, alias="organisationId"), + team_id: Optional[int] = Query(None, alias="teamId"), + campaign: Optional[str] = Query(None), + order_by: Optional[str] = Query("priority", alias="orderBy"), + country: Optional[str] = Query(None), + order_by_type: Optional[str] = Query("ASC", alias="orderByType"), + page: Optional[int] = Query(1), + text_search: Optional[str] = Query(None, alias="textSearch"), + omit_map_results: Optional[bool] = Query(False, alias="omitMapResults"), + last_updated_gte: Optional[str] = Query(None, alias="lastUpdatedFrom"), + last_updated_lte: Optional[str] = Query(None, alias="lastUpdatedTo"), + created_gte: Optional[str] = Query(None, alias="createdFrom"), + created_lte: Optional[str] = Query(None, alias="createdTo"), + partner_id: Optional[int] = Query(None, alias="partnerId"), + partnership_from: Optional[str] = Query(None, alias="partnershipFrom"), + partnership_to: Optional[str] = Query(None, alias="partnershipTo"), + download_as_csv: Optional[bool] = Query(None, alias="downloadAsCSV"), + created_by_me: bool = Query(False, alias="createdByMe"), + mapped_by_me: bool = Query(False, alias="mappedByMe"), + favorited_by_me: bool = Query(False, alias="favoritedByMe"), + managed_by_me: bool = Query(False, alias="managedByMe"), + based_on_my_interests: bool = Query(False, alias="basedOnMyInterests"), + mapping_types_str: Optional[str] = Query(None, alias="mappingTypes"), + mapping_types_exact: Optional[bool] = Query(False, alias="mappingTypesExact"), + project_statuses_str: Optional[str] = Query(None, alias="projectStatuses"), + interests: Optional[str] = Query(None), + user: Optional[AuthUserDTO] = Depends(login_required_optional), + db: Database = Depends(get_db), +): + """ + List and search for projects + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - in: query + name: difficulty + type: string + - in: query + name: orderBy + type: string + default: priority + enum: [id,difficulty,priority,status,last_updated,due_date] + - in: query + name: orderByType + type: string + default: ASC + enum: [ASC, DESC] + - in: query + name: mappingTypes + type: string + - in: query + name: mappingTypesExact + type: boolean + default: false + description: if true, limits projects to match the exact mapping types requested + - in: query + name: organisationName + description: Organisation name to search for + type: string + - in: query + name: organisationId + description: Organisation ID to search for + type: integer + - in: query + name: campaign + description: Campaign name to search for + type: string + - in: query + name: page + description: Page of results user requested + type: integer + default: 1 + - in: query + name: textSearch + description: Text to search + type: string + - in: query + name: country + description: Project country + type: string + - in: query + name: action + description: Filter projects by possible actions + enum: [map, validate, any] + type: string + - in: query + name: projectStatuses + description: Authenticated PMs can search for archived or draft statuses + type: string + - in: query + name: lastUpdatedFrom + description: Filter projects whose last update date is equal or greater than a date + type: string + - in: query + name: lastUpdatedTo + description: Filter projects whose last update date is equal or lower than a date + type: string + - in: query + name: createdFrom + description: Filter projects whose creation date is equal or greater than a date + type: string + - in: query + name: createdTo + description: Filter projects whose creation date is equal or lower than a date + type: string + - in: query + name: interests + type: string + description: Filter by interest on project + default: null + - in: query + name: createdByMe + description: Limit to projects created by the authenticated user + type: boolean + default: false + - in: query + name: mappedByMe + description: Limit to projects mapped/validated by the authenticated user + type: boolean + default: false + - in: query + name: favoritedByMe + description: Limit to projects favorited by the authenticated user + type: boolean + default: false + - in: query + name: managedByMe + description: + Limit to projects that can be managed by the authenticated user, + excluding the ones created by them + type: boolean + default: false + - in: query + name: basedOnMyInterests + type: boolean + description: Filter projects based on user interests + default: false + - in: query + name: teamId + type: string + description: Filter by team on project + default: null + name: omitMapResults + type: boolean + description: If true, it will not return the project centroid's geometries. + default: false + responses: + 200: + description: Projects found + 404: + description: No projects found + 500: + description: Internal Server Error + """ + try: + user_id = user.id if user else None + user = await UserService.get_user_by_id(user_id, db) if user_id else None + + search_dto = ProjectSearchDTO( + preferred_locale=request.headers.get("accept-language"), + difficulty=difficulty, + action=action, + organisation_name=organisation_name, + organisation_id=organisation_id, + team_id=team_id, + campaign=campaign, + order_by=order_by, + country=country, + order_by_type=order_by_type, + page=page, + text_search=text_search, + omit_map_results=omit_map_results, + last_updated_gte=last_updated_gte, + last_updated_lte=last_updated_lte, + created_gte=created_gte, + created_lte=created_lte, + partner_id=partner_id, + partnership_from=partnership_from, + partnership_to=partnership_to, + download_as_csv=download_as_csv, + mapping_types=( + list(map(str, mapping_types_str.split(","))) + if mapping_types_str + else None + ), + mapping_types_exact=mapping_types_exact, + project_statuses=( + list(map(str, project_statuses_str.split(","))) + if project_statuses_str + else None + ), + interests=map(int, interests.split(",")) if interests else None, ) - project_statuses_str = request.args.get("projectStatuses") - if project_statuses_str: - search_dto.project_statuses = map(str, project_statuses_str.split(",")) - interests_str = request.args.get("interests") - if interests_str: - search_dto.interests = map(int, interests_str.split(",")) - search_dto.validate() - - return search_dto - - -class ProjectsAllAPI(ProjectSearchBase): - @token_auth.login_required(optional=True) - def get(self): - """ - List and search for projects - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - in: query - name: difficulty - type: string - - in: query - name: orderBy - type: string - default: priority - enum: [id,difficulty,priority,status,last_updated,due_date,percent_mapped,percent_validated] - - in: query - name: orderByType - type: string - default: ASC - enum: [ASC, DESC] - - in: query - name: mappingTypes - type: string - - in: query - name: mappingTypesExact - type: boolean - default: false - description: if true, limits projects to match the exact mapping types requested - - in: query - name: organisationName - description: Organisation name to search for - type: string - - in: query - name: organisationId - description: Organisation ID to search for - type: integer - - in: query - name: campaign - description: Campaign name to search for - type: string - - in: query - name: page - description: Page of results user requested - type: integer - default: 1 - - in: query - name: textSearch - description: Text to search - type: string - - in: query - name: country - description: Project country - type: string - - in: query - name: action - description: Filter projects by possible actions - enum: [map, validate, any] - type: string - - in: query - name: projectStatuses - description: Authenticated PMs can search for archived or draft statuses - type: string - - in: query - name: lastUpdatedFrom - description: Filter projects whose last update date is equal or greater than a date - type: string - - in: query - name: lastUpdatedTo - description: Filter projects whose last update date is equal or lower than a date - type: string - - in: query - name: createdFrom - description: Filter projects whose creation date is equal or greater than a date - type: string - - in: query - name: createdTo - description: Filter projects whose creation date is equal or lower than a date - type: string - - in: query - name: interests - type: string - description: Filter by interest on project - default: null - - in: query - name: createdByMe - description: Limit to projects created by the authenticated user - type: boolean - default: false - - in: query - name: mappedByMe - description: Limit to projects mapped/validated by the authenticated user - type: boolean - default: false - - in: query - name: favoritedByMe - description: Limit to projects favorited by the authenticated user - type: boolean - default: false - - in: query - name: managedByMe - description: - Limit to projects that can be managed by the authenticated user, - excluding the ones created by them - type: boolean - default: false - - in: query - name: basedOnMyInterests - type: boolean - description: Filter projects based on user interests - default: false - - in: query - name: teamId - type: string - description: Filter by team on project - default: null - name: omitMapResults - type: boolean - description: If true, it will not return the project centroid's geometries. - default: false - - in: query - name: partnerId - type: int - description: Limit to projects currently linked to a specific partner ID - default: 1 - - in: query - name: partnershipFrom - type: date - description: Limit to projects with partners that began greater than or equal to a date - default: "2017-04-11" - - in: query - name: partnershipTo - type: date - description: Limit to projects with partners that ended less than or equal to a date - default: "2018-04-11" - - in: query - name: downloadAsCSV - type: boolean - description: Set to true to download search results as a CSV - default: false - responses: - 200: - description: Projects found - 400: - description: Bad input. - 401: - description: Search parameters partnerId, partnershipFrom, partnershipTo are not allowed for this user. - 404: - description: No projects found - 500: - description: Internal Server Error - """ - try: - user = None - user_id = token_auth.current_user() - if user_id: - user = UserService.get_user_by_id(user_id) - search_dto = self.setup_search_dto() + if user: + authenticated_user_id = user.id + if created_by_me: + search_dto.created_by = authenticated_user_id + if mapped_by_me: + search_dto.mapped_by = authenticated_user_id + if favorited_by_me: + search_dto.favorited_by = authenticated_user_id + if managed_by_me: + search_dto.managed_by = authenticated_user_id + if based_on_my_interests: + search_dto.based_on_user_interests = authenticated_user_id - if search_dto.omit_map_results and search_dto.download_as_csv: - return { + if search_dto.omit_map_results and search_dto.download_as_csv: + return JSONResponse( + content={ "Error": "omitMapResults and downloadAsCSV cannot be both set to true" - }, 400 + }, + status_code=400, + ) - if ( - search_dto.partnership_from is not None - or search_dto.partnership_to is not None - ) and search_dto.partner_id is None: - return { + if ( + search_dto.partnership_from is not None + or search_dto.partnership_to is not None + ) and search_dto.partner_id is None: + return JSONResponse( + content={ "Error": "partnershipFrom or partnershipTo cannot be provided without partnerId" - }, 400 - - if ( - search_dto.partner_id is not None - and search_dto.partnership_from is not None - and search_dto.partnership_to is not None - and search_dto.partnership_from > search_dto.partnership_to - ): - return { + }, + status_code=400, + ) + + if ( + search_dto.partner_id is not None + and search_dto.partnership_from is not None + and search_dto.partnership_to is not None + and search_dto.partnership_from > search_dto.partnership_to + ): + return JSONResponse( + content={ "Error": "partnershipFrom cannot be greater than partnershipTo" - }, 400 - - if any( - map( - lambda x: x is not None, - [ - search_dto.partner_id, - search_dto.partnership_from, - search_dto.partnership_to, - ], - ) - ) and (user is None or not user.role == UserRole.ADMIN.value): - error_msg = "Only admins can search projects by partnerId, partnershipFrom, partnershipTo" - return {"Error": error_msg}, 401 + }, + status_code=400, + ) - if search_dto.download_as_csv: - all_results_csv = ProjectSearchService.search_projects_as_csv( - search_dto, user - ) + if any( + map( + lambda x: x is not None, + [ + search_dto.partner_id, + search_dto.partnership_from, + search_dto.partnership_to, + ], + ) + ) and (user is None or not user.role == UserRole.ADMIN.value): + error_msg = "Only admins can search projects by partnerId, partnershipFrom, partnershipTo" + return JSONResponse(content={"Error": error_msg}, status_code=401) - return send_file( - io.BytesIO(all_results_csv.encode()), - mimetype="text/csv", - as_attachment=True, - download_name="projects_search_result.csv", - ) + if search_dto.download_as_csv: + if user: + user = user.id + all_results_csv = await ProjectSearchService.search_projects_as_csv( + search_dto, user, db, True + ) + return StreamingResponse( + iter([all_results_csv]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=data.csv"}, + ) + results_dto = await ProjectSearchService.search_projects(search_dto, user, db) + return results_dto + except NotFound: + return JSONResponse(content={"mapResults": {}, "results": []}, status_code=200) + except (KeyError, ValueError) as e: + error_msg = f"Projects GET - {str(e)}" + return JSONResponse(content={"Error": error_msg}, status_code=400) - results_dto = ProjectSearchService.search_projects(search_dto, user) - return results_dto.to_primitive(), 200 - except NotFound: - return {"mapResults": {}, "results": []}, 200 - except (KeyError, ValueError) as e: - error_msg = f"Projects GET - {str(e)}" - return {"Error": error_msg}, 400 - - -class ProjectsQueriesBboxAPI(Resource): - @token_auth.login_required - def get(self): - """ - List and search projects by bounding box - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - default: en - - in: query - name: bbox - description: comma separated list xmin, ymin, xmax, ymax - type: string - required: true - default: 34.404,-1.034, 34.717,-0.624 - - in: query - name: srid - description: srid of bbox coords - type: integer - default: 4326 - - in: query - name: createdByMe - description: limit to projects created by authenticated user - type: boolean - required: true - default: false - - responses: - 200: - description: ok - 400: - description: Client Error - Invalid Request - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - authenticated_user_id - ) - if len(orgs_dto.organisations) < 1: - return { + +@router.get("/queries/bbox/") +async def get_by_bbox( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + List and search projects by bounding box + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + default: en + - in: query + name: bbox + description: comma separated list xmin, ymin, xmax, ymax + type: string + required: true + default: 34.404,-1.034, 34.717,-0.624 + - in: query + name: srid + description: srid of bbox coords + type: integer + default: 4326 + - in: query + name: createdByMe + description: limit to projects created by authenticated user + type: boolean + required: true + default: false + + responses: + 200: + description: ok + 400: + description: Client Error - Invalid Request + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + authenticated_user_id, db + ) + if len(orgs_dto.organisations) < 1: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - try: - search_dto = ProjectSearchBBoxDTO() - search_dto.bbox = map(float, request.args.get("bbox").split(",")) - search_dto.input_srid = request.args.get("srid") - search_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - created_by_me = ( - strtobool(request.args.get("createdByMe")) - if request.args.get("createdByMe") - else False - ) - if created_by_me: - search_dto.project_author = authenticated_user_id - search_dto.validate() - except Exception as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { + try: + bbox = map(float, request.query_params.get("bbox").split(",")) + input_srid = request.query_params.get("srid") + search_dto = ProjectSearchBBoxDTO( + bbox=bbox, + input_srid=input_srid, + preferred_locale=request.headers.get("accept-language", "en"), + ) + created_by_me = ( + strtobool(request.query_params.get("createdByMe")) + if request.query_params.get("createdByMe") + else False + ) + if created_by_me: + search_dto.project_author = authenticated_user_id + # search_dto.validate() + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": f"Error validating request: {str(e)}", "SubCode": "InvalidData", - }, 400 - try: - geojson = ProjectSearchService.get_projects_geojson(search_dto) - return geojson, 200 - except BBoxTooBigError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - except ProjectSearchServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - - -class ProjectsQueriesOwnerAPI(ProjectSearchBase): - @token_auth.login_required - def get(self): - """ - Get all projects for logged in admin - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - responses: - 200: - description: All mapped tasks validated - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Admin has no projects - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - orgs_dto = OrganisationService.get_organisations_managed_by_user_as_dto( - authenticated_user_id + }, + status_code=400, + ) + try: + geojson = await ProjectSearchService.get_projects_geojson(search_dto, db) + return JSONResponse(content=geojson, status_code=200) + except BBoxTooBigError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, ) - if len(orgs_dto.organisations) < 1: - return { + except ProjectSearchServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + + +@router.get("/queries/myself/owner/") +async def get_my_projects( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get all projects for logged in admin + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + responses: + 200: + description: All mapped tasks validated + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Admin has no projects + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( + authenticated_user_id, db + ) + if len(orgs_dto.organisations) < 1: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) + + search_dto = setup_search_dto(request) + preferred_locale = request.headers.get("accept-language", "en") + admin_projects = await ProjectAdminService.get_projects_for_admin( + authenticated_user_id, preferred_locale, search_dto, db + ) + return admin_projects + - search_dto = self.setup_search_dto() - admin_projects = ProjectAdminService.get_projects_for_admin( - authenticated_user_id, - request.environ.get("HTTP_ACCEPT_LANGUAGE"), - search_dto, +@router.get("/queries/{username}/touched/") +async def get_mapped_projects( + request: Request, username, db: Database = Depends(get_db) +): + """ + Gets projects user has mapped + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: username + in: path + description: The users username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: Mapped projects found + 404: + description: User not found + 500: + description: Internal Server Error + """ + locale = ( + request.headers.get("accept-language") + if request.headers.get("accept-language") + else "en" + ) + user_dto = await UserService.get_mapped_projects(username, locale, db) + return user_dto + + +@router.get("/{project_id}/queries/summary/") +async def get_project_summary( + request: Request, project_id: int, db: Database = Depends(get_db) +): + """ + Gets project summary + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: The ID of the project + required: true + type: integer + default: 1 + responses: + 200: + description: Project Summary + 404: + description: Project not found + 500: + description: Internal Server Error + """ + preferred_locale = request.headers.get("accept-language") + summary = await ProjectService.get_project_summary(project_id, db, preferred_locale) + return summary + + +@router.get("/{project_id}/queries/nogeometries/") +async def get_no_geometries( + request: Request, project_id: int, db: Database = Depends(get_db) +): + """ + Get HOT Project for mapping + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: query + name: as_file + type: boolean + description: Set to true if file download is preferred + default: False + responses: + 200: + description: Project found + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + as_file = ( + strtobool(request.query_params.get("as_file")) + if request.query_params.get("as_file") + else False ) - return admin_projects.to_primitive(), 200 - - -class ProjectsQueriesTouchedAPI(Resource): - def get(self, username): - """ - Gets projects user has mapped - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: username - in: path - description: The users username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: Mapped projects found - 404: - description: User not found - 500: - description: Internal Server Error - """ - locale = ( - request.environ.get("HTTP_ACCEPT_LANGUAGE") - if request.environ.get("HTTP_ACCEPT_LANGUAGE") - else "en" + locale = request.headers.get("accept-language") + project_dto = await ProjectService.get_project_dto_for_mapper( + project_id, None, db, locale, True ) - user_dto = UserService.get_mapped_projects(username, locale) - return user_dto.to_primitive(), 200 - - -class ProjectsQueriesSummaryAPI(Resource): - def get(self, project_id: int): - """ - Gets project summary - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: The ID of the project - required: true - type: integer - default: 1 - responses: - 200: - description: Project Summary - 404: - description: Project not found - 500: - description: Internal Server Error - """ - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - summary = ProjectService.get_project_summary(project_id, preferred_locale) - return summary.to_primitive(), 200 - - -class ProjectsQueriesNoGeometriesAPI(Resource): - def get(self, project_id): - """ - Get HOT Project for mapping - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: query - name: as_file - type: boolean - description: Set to true if file download is preferred - default: False - responses: - 200: - description: Project found - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - as_file = ( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else False - ) - locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - project_dto = ProjectService.get_project_dto_for_mapper( - project_id, None, locale, True + # Handle file download if requested + if as_file: + project_dto_str = geojson.dumps( + project_dto, indent=4 + ) # Convert to GeoJSON string + file_bytes = io.BytesIO(project_dto_str.encode("utf-8")) + file_bytes.seek(0) # Reset stream position + + return StreamingResponse( + file_bytes, + media_type="application/geo+json", + headers={ + "Content-Disposition": f'attachment; filename="project_{project_id}.geojson"' + }, ) - project_dto = project_dto.to_primitive() - if as_file: - return send_file( - io.BytesIO(geojson.dumps(project_dto).encode("utf-8")), - mimetype="application/json", - as_attachment=True, - download_name=f"project_{str(project_id)}.json", - ) + return project_dto + except ProjectServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + finally: + # this will try to unlock tasks that have been locked too long + try: + ProjectService.auto_unlock_tasks(project_id) + except Exception as e: + logger.critical(str(e)) - return project_dto, 200 - except ProjectServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - finally: - # this will try to unlock tasks that have been locked too long - try: - ProjectService.auto_unlock_tasks(project_id) - except Exception as e: - current_app.logger.critical(str(e)) - - -class ProjectsQueriesNoTasksAPI(Resource): - @token_auth.login_required - def get(self, project_id): - """ - Retrieves a Tasking-Manager project - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project found - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user(), project_id - ): - return { + +@router.get("/{project_id}/queries/notasks/") +async def get_notasks( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Retrieves a Tasking-Manager project + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project found + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + if not await ProjectAdminService.is_user_action_permitted_on_project( + request.user.display_name, project_id, db + ): + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - - project_dto = ProjectAdminService.get_project_dto_for_admin(project_id) - return project_dto.to_primitive(), 200 - - -class ProjectsQueriesAoiAPI(Resource): - def get(self, project_id): - """ - Get AOI of Project - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - in: query - name: as_file - type: boolean - description: Set to false if file download not preferred - default: True - responses: - 200: - description: Project found - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - as_file = ( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else True + }, + status_code=403, ) - project_aoi = ProjectService.get_project_aoi(project_id) + project_dto = await ProjectAdminService.get_project_dto_for_admin(project_id, db) + return project_dto + + +@router.get("/{project_id}/queries/aoi/") +async def get_aoi( + project_id: int = Path(..., description="Unique project ID"), + as_file: bool = Query( + default=False, + alias="as_file", + description="Set to true if file download preferred", + ), + db: Database = Depends(get_db), +): + """ + Get AOI of Project + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - in: query + name: as_file + type: boolean + description: Set to true if file download preferred + default: False + responses: + 200: + description: Project AOI returned + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + project_aoi = await ProjectService.get_project_aoi(project_id, db) if as_file: - return send_file( - io.BytesIO(geojson.dumps(project_aoi).encode("utf-8")), - mimetype="application/json", - as_attachment=True, - download_name=f"{str(project_id)}.geojson", + aoi_str = geojson.dumps(project_aoi, indent=4) + return Response( + content=aoi_str, + media_type="application/geo+json", + headers={ + "Content-Disposition": f'attachment; filename="{project_id}-aoi.geojson"' + }, ) - return project_aoi, 200 - - -class ProjectsQueriesPriorityAreasAPI(Resource): - def get(self, project_id): - """ - Get Priority Areas of a project - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project found - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - priority_areas = ProjectService.get_project_priority_areas(project_id) - return priority_areas, 200 - except ProjectServiceError: - return {"Error": "Unable to fetch project"}, 403 - - -class ProjectsQueriesFeaturedAPI(Resource): - def get(self): - """ - Get featured projects - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: false - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Featured projects - 500: - description: Internal Server Error - """ - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - projects_dto = ProjectService.get_featured_projects(preferred_locale) - return projects_dto.to_primitive(), 200 - - -class ProjectQueriesSimilarProjectsAPI(Resource): - @token_auth.login_required(optional=True) - def get(self, project_id): - """ - Get similar projects - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: false - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID to get similar projects for - required: true - type: integer - default: 1 - - in: query - name: limit - type: integer - description: Number of similar projects to return - default: 4 - responses: - 200: - description: Similar projects - 404: - description: Project not found or project is not published - 500: - description: Internal Server Error - """ - authenticated_user_id = ( - token_auth.current_user() if token_auth.current_user() else None - ) - limit = int(request.args.get("limit", 4)) - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE", "en") - projects_dto = ProjectRecommendationService.get_similar_projects( - project_id, authenticated_user_id, preferred_locale, limit + return project_aoi + + except ProjectServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=400) + + +@router.get("/{project_id}/queries/priority-areas/") +async def get_priority_areas(project_id: int, db: Database = Depends(get_db)): + """ + Get Priority Areas of a project + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project found + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + priority_areas = await ProjectService.get_project_priority_areas(project_id, db) + return priority_areas + except ProjectServiceError: + return JSONResponse( + content={"Error": "Unable to fetch project"}, status_code=403 ) - return projects_dto.to_primitive(), 200 - - -class ProjectQueriesActiveProjectsAPI(Resource): - @token_auth.login_required(optional=True) - def get(self): - """ - Get active projects - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: false - type: string - default: Token sessionTokenHere== - - name: interval - in: path - description: Time interval in hours to get active project - required: false - type: integer - default: 24 - responses: - 200: - description: Active projects geojson - 404: - description: Project not found or project is not published - 500: - description: Internal Server Error - """ - interval = request.args.get("interval", "24") - if not interval.isdigit(): - return { + + +@router.get("/queries/featured/") +async def get_featured(request: Request, db: Database = Depends(get_db)): + """ + Get featured projects + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: false + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Featured projects + 500: + description: Internal Server Error + """ + preferred_locale = request.headers.get("accept-language") + projects_dto = await ProjectService.get_featured_projects(preferred_locale, db) + return projects_dto + + +@router.get("/queries/{project_id}/similar-projects/") +async def get_similar_projects( + request: Request, project_id: int, db: Database = Depends(get_db) +): + """ + Get similar projects + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: false + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID to get similar projects for + required: true + type: integer + default: 1 + - in: query + name: limit + type: integer + description: Number of similar projects to return + default: 4 + responses: + 200: + description: Similar projects + 404: + description: Project not found or project is not published + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + limit = int(request.query_params.get("limit", 4)) + preferred_locale = request.headers.get("accept-language", "en") + projects_dto = await ProjectRecommendationService.get_similar_projects( + db, project_id, authenticated_user_id, preferred_locale, limit + ) + return projects_dto + + +@router.get("/queries/active/") +async def get_active(request: Request, db: Database = Depends(get_db)): + """ + Get active projects + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: false + type: string + default: Token sessionTokenHere== + - name: interval + in: path + description: Time interval in hours to get active project + required: false + type: integer + default: 24 + responses: + 200: + description: Active projects geojson + 404: + description: Project not found or project is not published + 500: + description: Internal Server Error + """ + interval = request.query_params.get("interval", "24") + if not interval.isdigit(): + return JSONResponse( + content={ "Error": "Interval must be a number greater than 0 and less than or equal to 24" - }, 400 - interval = int(interval) - if interval <= 0 or interval > 24: - return { + }, + status_code=400, + ) + interval = int(interval) + if interval <= 0 or interval > 24: + return JSONResponse( + content={ "Error": "Interval must be a number greater than 0 and less than or equal to 24" - }, 400 - projects_dto = ProjectService.get_active_projects(interval) - return projects_dto, 200 + }, + status_code=400, + ) + projects_dto = await ProjectService.get_active_projects(interval, db) + return projects_dto diff --git a/backend/api/projects/statistics.py b/backend/api/projects/statistics.py index afe0bd02b9..547bfa9cdb 100644 --- a/backend/api/projects/statistics.py +++ b/backend/api/projects/statistics.py @@ -1,91 +1,101 @@ -from flask_restful import Resource -from backend.services.stats_service import StatsService +from databases import Database +from fastapi import APIRouter, Depends + +from backend.db import get_db from backend.services.project_service import ProjectService +from backend.services.stats_service import StatsService + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsStatisticsQueriesPopularAPI(Resource): - def get(self): - """ - Get popular projects - --- - tags: - - projects - produces: - - application/json - responses: - 200: - description: Popular Projects stats - 500: - description: Internal Server Error - """ - stats = StatsService.get_popular_projects() - return stats.to_primitive(), 200 +@router.get("/queries/popular/") +async def get_popular(db: Database = Depends(get_db)): + """ + Get popular projects + --- + tags: + - projects + produces: + - application/json + responses: + 200: + description: Popular Projects stats + 500: + description: Internal Server Error + """ + stats = await StatsService.get_popular_projects(db) + return stats -class ProjectsStatisticsAPI(Resource): - def get(self, project_id): - """ - Get Project Stats - --- - tags: - - projects - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Project stats - 404: - description: Not found - 500: - description: Internal Server Error - """ - # preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - summary = ProjectService.get_project_stats(project_id) - return summary.to_primitive(), 200 +@router.get("/{project_id}/statistics/") +async def get_project_stats(project_id: int, db: Database = Depends(get_db)): + """ + Get Project Stats + --- + tags: + - projects + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Project stats + 404: + description: Not found + 500: + description: Internal Server Error + """ + summary = await ProjectService.get_project_stats(project_id, db) + return summary -class ProjectsStatisticsQueriesUsernameAPI(Resource): - def get(self, project_id, username): - """ - Get detailed stats about user - --- - tags: - - projects - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: User found - 404: - description: User not found - 500: - description: Internal Server Error - """ - stats_dto = ProjectService.get_project_user_stats(project_id, username) - return stats_dto.to_primitive(), 200 +@router.get("/{project_id}/statistics/queries/{username}/") +async def get_project_user_stats( + project_id: int, username: str, db: Database = Depends(get_db) +): + """ + Get detailed stats about user + --- + tags: + - projects + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: User found + 404: + description: User not found + 500: + description: Internal Server Error + """ + stats_dto = await ProjectService.get_project_user_stats(project_id, username, db) + return stats_dto diff --git a/backend/api/projects/teams.py b/backend/api/projects/teams.py index 3dbb12a337..fa0d85a181 100644 --- a/backend/api/projects/teams.py +++ b/backend/api/projects/teams.py @@ -1,239 +1,294 @@ -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Body, Depends, Request +from fastapi.responses import JSONResponse +from loguru import logger -from backend.services.team_service import TeamService, TeamServiceError +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.project_admin_service import ProjectAdminService from backend.services.project_service import ProjectService -from backend.services.users.authentication_service import token_auth +from backend.services.team_service import TeamService, TeamServiceError +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) -class ProjectsTeamsAPI(Resource): - @token_auth.login_required - def get(self, project_id): - """Get teams assigned with a project - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Teams listed successfully - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - # Check if project exists - ProjectService.exists(project_id) - teams_dto = TeamService.get_project_teams_as_dto(project_id) - return teams_dto.to_primitive(), 200 +@router.get("/{project_id}/teams/") +async def get_project_teams( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Get teams assigned with a project + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Teams listed successfully + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + # Check if project exists + await ProjectService.exists(project_id, db) + teams_dto = await TeamService.get_project_teams_as_dto(project_id, db) + return teams_dto - @token_auth.login_required - def post(self, team_id, project_id): - """Assign a team to a project - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: The role that the team will have on the project - schema: - properties: - role: - type: string - responses: - 201: - description: Team project assignment created - 401: - description: Forbidden, if user is not a manager of the project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - if not TeamService.is_user_team_manager(team_id, token_auth.current_user()): - return { + +@router.post("/{project_id}/teams/{team_id}/") +async def assign_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + project_id: int = None, + data: dict = Body(...), +): + """Assign a team to a project + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: The role that the team will have on the project + schema: + properties: + role: + type: string + responses: + 201: + description: Team project assignment created + 401: + description: Forbidden, if user is not a manager of the project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + if not await TeamService.is_user_team_manager(team_id, user.id, db): + return JSONResponse( + content={ "Error": "User is not an admin or a manager for the team", "SubCode": "UserPermissionError", - }, 401 + }, + status_code=403, + ) - try: - role = request.get_json(force=True)["role"] - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 + try: + role = data["role"] + except ValueError as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) - try: - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user, project_id - ): - raise ValueError() - TeamService.add_team_project(team_id, project_id, role) - return ( - { - "Success": "Team {} assigned to project {} with role {}".format( - team_id, project_id, role - ) - }, - 201, - ) - except ValueError: - return { + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + await TeamService.add_team_project(team_id, project_id, role, db) + return JSONResponse( + content={ + "Success": "Team {} assigned to project {} with role {}".format( + team_id, project_id, role + ) + }, + status_code=201, + ) + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) + - @token_auth.login_required - def patch(self, team_id, project_id): - """Update role of a team on a project - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: The role that the team will have on the project - schema: - properties: - role: - type: string - responses: - 201: - description: Team project assignment created - 401: - description: Forbidden, if user is not a manager of the project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - role = request.get_json(force=True)["role"] - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 +@router.patch("/{team_id}/projects/{project_id}/") +async def patch_project_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + project_id: int = None, + data: dict = Body(...), +): + """Update role of a team on a project + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: The role that the team will have on the project + schema: + properties: + role: + type: string + responses: + 201: + description: Team project assignment created + 401: + description: Forbidden, if user is not a manager of the project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + role = data["role"] + except ValueError as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) - try: - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user, project_id - ): - raise ValueError() - TeamService.change_team_role(team_id, project_id, role) - return {"Status": "Team role updated successfully."}, 200 - except ValueError: - return { + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + await TeamService.change_team_role(team_id, project_id, role, db) + return JSONResponse( + content={"Status": "Team role updated successfully."}, status_code=201 + ) + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - except TeamServiceError as e: - return str(e), 402 + }, + status_code=403, + ) + except TeamServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=402) + - @token_auth.login_required - def delete(self, team_id, project_id): - """ - Deletes the specified team project assignment - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: message_id - in: path - description: Unique message ID - required: true - type: integer - default: 1 - responses: - 200: - description: Team unassigned of the project - 401: - description: Forbidden, if user is not a manager of the project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user, project_id - ): - raise ValueError() - TeamService.delete_team_project(team_id, project_id) - return {"Success": True}, 200 - except ValueError: - return { +@router.delete("/{team_id}/projects/{project_id}/") +async def remove_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + project_id: int = None, +): + """ + Deletes the specified team project assignment + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: message_id + in: path + description: Unique message ID + required: true + type: integer + default: 1 + responses: + 200: + description: Team unassigned of the project + 401: + description: Forbidden, if user is not a manager of the project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + if not await ProjectAdminService.is_user_action_permitted_on_project( + user.id, project_id, db + ): + raise ValueError() + await TeamService.delete_team_project(team_id, project_id, db) + return JSONResponse(content={"Success": True}, status_code=200) + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) diff --git a/backend/api/system/applications.py b/backend/api/system/applications.py index 13820c349c..efb299cfc1 100644 --- a/backend/api/system/applications.py +++ b/backend/api/system/applications.py @@ -1,132 +1,156 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Request, Response +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.application import Application from backend.services.application_service import ApplicationService -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) -class SystemApplicationsRestAPI(Resource): - @token_auth.login_required - def get(self): - """ - Gets application keys for a user - --- - tags: - - system - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: User keys retrieved - 404: - description: User has no keys - 500: - description: A problem occurred - """ - tokens = ApplicationService.get_all_tokens_for_logged_in_user( - token_auth.current_user() - ) - if len(tokens) == 0: - return 400 - return tokens.to_primitive(), 200 - @token_auth.login_required - def post(self): - """ - Creates an application key for the user - --- - tags: - - system - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Key generated successfully - 302: - description: User is not authorized to create a key - 500: - description: A problem occurred - """ - token = ApplicationService.create_token(token_auth.current_user()) - return token.to_primitive(), 200 +@router.get("/authentication/applications/") +async def get_application_keys( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Gets application keys for a user + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: User keys retrieved + 404: + description: User has no keys + 500: + description: A problem occurred + """ + tokens = await ApplicationService.get_all_tokens_for_logged_in_user(user.id, db) + return tokens.model_dump(by_alias=True) - def patch(self, application_key): - """ - Checks the validity of an application key - --- - tags: - - system - produces: - - application/json - parameters: - - in: path - name: application_key - description: Application key to test - type: string - required: true - default: 1 - responses: - 200: - description: Key is valid - 302: - description: Key is not valid - 500: - description: A problem occurred - """ - is_valid = ApplicationService.check_token(application_key) - if is_valid: - return 200 - else: - return 302 - @token_auth.login_required - def delete(self, application_key): - """ - Deletes an application key for a user - --- - tags: - - system - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: path - name: application_key - description: Application key to remove - type: string - required: true - default: 1 - responses: - 200: - description: Key deleted successfully - 302: - description: User is not authorized to delete the key - 404: - description: Key not found - 500: - description: A problem occurred - """ - token = ApplicationService.get_token(application_key) - if token.user == token_auth.current_user(): - token.delete() - return 200 - else: - return 302 +@router.post("/authentication/applications/") +async def post_application_key( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Creates an application key for the user + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Key generated successfully + 302: + description: User is not authorized to create a key + 500: + description: A problem occurred + """ + token = await ApplicationService.create_token(user.id, db) + return token.model_dump(by_alias=True) + + +@router.patch("/authentication/applications/{application_key}/") +async def patch_application_key( + request: Request, application_key: str, db: Database = Depends(get_db) +): + """ + Checks the validity of an application key + --- + tags: + - system + produces: + - application/json + parameters: + - in: path + name: application_key + description: Application key to test + type: string + required: true + default: 1 + responses: + 200: + description: Key is valid + 302: + description: Key is not valid + 500: + description: A problem occurred + """ + is_valid = await ApplicationService.check_token(application_key, db) + if is_valid: + return Response(status_code=200) + else: + return Response(status_code=302) + + +@router.delete("/authentication/applications/{application_key}/") +async def delete_application_key( + request: Request, + application_key: str, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Deletes an application key for a user + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: path + name: application_key + description: Application key to remove + type: string + required: true + default: 1 + responses: + 200: + description: Key deleted successfully + 302: + description: User is not authorized to delete the key + 404: + description: Key not found + 500: + description: A problem occurred + """ + token = await ApplicationService.get_token(application_key, db) + if token.user == user.id: + await Application.delete(token, db) + return Response(status_code=200) + else: + return Response(status_code=302) diff --git a/backend/api/system/authentication.py b/backend/api/system/authentication.py index 58453ddad6..8358b78e72 100644 --- a/backend/api/system/authentication.py +++ b/backend/api/system/authentication.py @@ -1,164 +1,207 @@ -from flask import current_app, request -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Query +from fastapi.logger import logger +from fastapi.responses import JSONResponse from oauthlib.oauth2.rfc6749.errors import InvalidGrantError from backend import osm -from backend.config import EnvironmentConfig +from backend.config import settings +from backend.db import get_db from backend.services.users.authentication_service import ( AuthenticationService, AuthServiceError, ) +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) + -class SystemAuthenticationLoginAPI(Resource): - def get(self): - """ - Redirects user to OSM to authenticate - --- - tags: - - system - produces: - - application/json - parameters: - - in: query - name: redirect_uri - description: Route to redirect user once authenticated - type: string - default: /take/me/here - responses: - 200: - description: oauth2 params - """ - redirect_uri = request.args.get( - "redirect_uri", EnvironmentConfig.OAUTH_REDIRECT_URI +# class SystemAuthenticationLoginAPI(): +@router.get("/authentication/login/") +async def login( + redirect_uri: str = Query( + default=settings.OAUTH_REDIRECT_URI, + description="Route to redirect user once authenticated", + ) +): + """ + Redirects user to OSM to authenticate + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: redirect_uri + description: Route to redirect user once authenticated + type: string + default: /take/me/here + responses: + 200: + description: oauth2 params + """ + authorize_url = f"{settings.OSM_SERVER_URL}/oauth2/authorize" + state = AuthenticationService.generate_random_state() + + osm.redirect_uri = redirect_uri + osm.state = state + + login_url, state = osm.authorization_url(authorize_url) + return {"auth_url": login_url, "state": state} + + +# class SystemAuthenticationCallbackAPI(): +@router.get("/authentication/callback/") +async def callback( + authorization_code: str | None = Query( + None, alias="code", description="Code obtained after user authorization" + ), + redirect_uri: str = Query( + settings.OAUTH_REDIRECT_URI, + description="Route to redirect user once authenticated", + ), + email: str | None = Query( + None, + alias="email_address", + description="Email address to used for email notifications from TM.", + ), + db: Database = Depends(get_db), +): + """ + Handles the OSM OAuth callback + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: redirect_uri + description: Route to redirect user once authenticated + type: string + default: /take/me/here + required: false + - in: query + name: code + description: Code obtained after user authorization + type: string + required: true + - in: query + name: email_address + description: Email address to used for email notifications from TM. + type: string + required: false + responses: + 302: + description: Redirects to login page, or login failed page + 400: + description: Missing/Invalid code parameter + 500: + description: A problem occurred authenticating the user + 502: + description: A problem occurred negotiating with the OSM API + """ + + token_url = f"{settings.OSM_SERVER_URL}/oauth2/token" + if authorization_code is None: + return JSONResponse( + content={"SubCode": "InvalidData", "Error": "Missing code parameter"}, + status_code=400, ) - authorize_url = f"{EnvironmentConfig.OSM_SERVER_URL}/oauth2/authorize" - state = AuthenticationService.generate_random_state() - - osm.redirect_uri = redirect_uri - osm.state = state - - login_url, state = osm.authorization_url(authorize_url) - return {"auth_url": login_url, "state": state}, 200 - - -class SystemAuthenticationCallbackAPI(Resource): - def get(self): - """ - Handles the OSM OAuth callback - --- - tags: - - system - produces: - - application/json - parameters: - - in: query - name: redirect_uri - description: Route to redirect user once authenticated - type: string - default: /take/me/here - required: false - - in: query - name: code - description: Code obtained after user authorization - type: string - required: true - - in: query - name: email_address - description: Email address to used for email notifications from TM. - type: string - required: false - responses: - 302: - description: Redirects to login page, or login failed page - 400: - description: Missing/Invalid code parameter - 500: - description: A problem occurred authenticating the user - 502: - description: A problem occurred negotiating with the OSM API - """ - - token_url = f"{EnvironmentConfig.OSM_SERVER_URL}/oauth2/token" - authorization_code = request.args.get("code", None) - if authorization_code is None: - return {"SubCode": "InvalidData", "Error": "Missing code parameter"}, 400 - - email = request.args.get("email_address", None) - redirect_uri = request.args.get( - "redirect_uri", EnvironmentConfig.OAUTH_REDIRECT_URI + + osm.redirect_uri = redirect_uri + try: + osm_resp = osm.fetch_token( + token_url=token_url, + client_secret=settings.OAUTH_CLIENT_SECRET, + code=authorization_code, ) - osm.redirect_uri = redirect_uri - try: - osm_resp = osm.fetch_token( - token_url=token_url, - client_secret=EnvironmentConfig.OAUTH_CLIENT_SECRET, - code=authorization_code, - ) - except InvalidGrantError: - return { + except InvalidGrantError: + return JSONResponse( + content={ "Error": "The provided authorization grant is invalid, expired or revoked", "SubCode": "InvalidGrantError", - }, 400 - if osm_resp is None: - current_app.logger.critical("Couldn't obtain token from OSM.") - return { + }, + status_code=400, + ) + if osm_resp is None: + logger.critical("Couldn't obtain token from OSM.") + return JSONResponse( + content={ "SubCode": "TokenFetchError", "Error": "Couldn't fetch token from OSM.", - }, 502 + }, + status_code=502, + ) - user_info_url = f"{EnvironmentConfig.OAUTH_API_URL}/user/details.json" - osm_response = osm.get(user_info_url) # Get details for the authenticating user + user_info_url = f"{settings.OAUTH_API_URL}/user/details.json" - if osm_response.status_code != 200: - current_app.logger.critical("Error response from OSM") - return { + osm_response = osm.get(user_info_url) # Get details for the authenticating user + if osm_response.status_code != 200: + logger.critical("Error response from OSM") + return JSONResponse( + content={ "SubCode": "OSMServiceError", "Error": "Couldn't fetch user details from OSM.", - }, 502 - - try: - user_params = AuthenticationService.login_user(osm_response.json(), email) - user_params["session"] = osm_resp - return user_params, 200 - except AuthServiceError: - return {"Error": "Unable to authenticate", "SubCode": "AuthError"}, 500 - - -class SystemAuthenticationEmailAPI(Resource): - def get(self): - """ - Authenticates user owns email address - --- - tags: - - system - produces: - - application/json - parameters: - - in: query - name: username - type: string - default: thinkwhere - - in: query - name: token - type: string - default: 1234dvsdf - responses: - 301: - description: Will redirect to email validation page - 403: - description: Forbidden - 404: - description: User not found - 500: - description: Internal Server Error - """ - try: - username = request.args.get("username") - token = request.args.get("token") - AuthenticationService.authenticate_email_token(username, token) - - return {"Status": "OK"}, 200 - - except AuthServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + }, + status_code=502, + ) + + try: + user_params = await AuthenticationService.login_user( + osm_response.json(), email, db + ) + user_params["session"] = osm_resp + return user_params + except AuthServiceError: + return JSONResponse( + content={"SubCode": "AuthError", "Error": "Unable to authenticate"}, + status_code=502, + ) + + +@router.get("/authentication/email/") +async def authenticate_email( + username: str = Query(..., description="Username, e.g. thinkwhere"), + token: str = Query(..., description="Authentication token, e.g. 1234dvsdf"), + db: Database = Depends(get_db), +): + """ + Authenticates user owns email address + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: username + type: string + default: thinkwhere + - in: query + name: token + type: string + default: 1234dvsdf + responses: + 301: + description: Will redirect to email validation page + 403: + description: Forbidden + 404: + description: User not found + 500: + description: Internal Server Error + """ + try: + await AuthenticationService.authenticate_email_token(username, token, db) + return JSONResponse(content={"Status": "OK"}, status_code=200) + + except AuthServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) diff --git a/backend/api/system/banner.py b/backend/api/system/banner.py index 272bc3e12d..19999752bc 100644 --- a/backend/api/system/banner.py +++ b/backend/api/system/banner.py @@ -1,96 +1,116 @@ -from flask import current_app -from flask_restful import Resource, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Body, Depends, Request +from fastapi.logger import logger +from pydantic import ValidationError +from fastapi.responses import JSONResponse -from backend.models.postgis.banner import Banner + +from backend.db import get_db from backend.models.dtos.banner_dto import BannerDTO +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.banner import Banner from backend.models.postgis.statuses import UserRole -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required from backend.services.users.user_service import UserService +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) + -class SystemBannerAPI(Resource): - def get(self): - """ - Returns a banner - --- - tags: - - system - produces: - - application/json - responses: - 200: - description: Fetched banner successfully - 500: - description: Internal Server Error - """ +@router.get("/banner/", response_model=BannerDTO) +async def get_banner(db: Database = Depends(get_db)): + """ + Returns a banner + --- + tags: + - system + produces: + - application/json + responses: + 200: + description: Fetched banner successfully + 500: + description: Internal Server Error + """ - banner = Banner.get() - return banner.as_dto().to_primitive(), 200 + banner = await Banner.get(db) + return banner - @token_auth.login_required - def patch(self): - """ - Updates the current banner in the DB - --- - tags: - - system - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for updating the banner. Message can be written in markdown (max 255 chars) \n - \n - Allowed tags are `a`, `b`, `i`, `h3`, `h4`, `h5`, `h6`, `p`, `pre`, `strong` - schema: - properties: - message: - description: The message to display on the banner. Max 255 characters allowed. - required: true - type: string - default: Welcome to the Tasking Manager - visible: - description: Whether the banner is visible or not - type: boolean - default: false - responses: - 201: - description: Banner updated successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - """ - try: - banner_dto = BannerDTO(request.get_json()) - banner_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": "Unable to create project", "SubCode": "InvalidData"}, 400 +@router.patch("/banner/", response_model=BannerDTO) +async def patch_banner( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), + banner: BannerDTO = Body(...), +): + """ + Updates the current banner in the DB + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for updating the banner. Message can be written in markdown (max 255 chars) \n + \n + Allowed tags are `a`, `b`, `i`, `h3`, `h4`, `h5`, `h6`, `p`, `pre`, `strong` + schema: + properties: + message: + description: The message to display on the banner. Max 255 characters allowed. + required: true + type: string + default: Welcome to the Tasking Manager + visible: + description: Whether the banner is visible or not + type: boolean + default: false + responses: + 201: + description: Banner updated successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + """ - # Check user permission for this action - authenticated_user_id = token_auth.current_user() - authenticated_user = UserService.get_user_by_id(authenticated_user_id) - if authenticated_user.role != UserRole.ADMIN.value: - return { + try: + banner_dto = banner + except ValidationError as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to create project", "SubCode": "InvalidData"}, + status_code=400, + ) + # Check user permission for this action + authenticated_user = await UserService.get_user_by_id(user.id, db) + if authenticated_user.role != UserRole.ADMIN.value: + return JSONResponse( + content={ "Error": "Banner can only be updated by system admins", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - banner_dto.message = Banner.to_html( - banner_dto.message - ) # Convert the markdown message to html - banner = Banner.get() - banner.update_from_dto(banner_dto) - return banner.as_dto().to_primitive(), 200 + banner_dto.message = Banner.to_html( + banner_dto.message if banner_dto.message is not None else "" + ) # Convert the markdown message to html + banner = await Banner.get(db) + updated_banner = await Banner.update_from_dto(banner, db, banner_dto) + return updated_banner diff --git a/backend/api/system/general.py b/backend/api/system/general.py index 968c787548..b66513546b 100644 --- a/backend/api/system/general.py +++ b/backend/api/system/general.py @@ -1,267 +1,290 @@ +from datetime import datetime + import requests -from flask import jsonify -from flask_restful import Resource, request, current_app -from flask_swagger import swagger +from databases import Database +from fastapi import APIRouter, Body, Depends, Request +from fastapi.responses import JSONResponse -from backend.services.settings_service import SettingsService -from backend.services.messaging.smtp_service import SMTPService +from backend.db import get_db from backend.models.postgis.release_version import ReleaseVersion +from backend.services.messaging.smtp_service import SMTPService +from backend.services.settings_service import SettingsService +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) -class SystemDocsAPI(Resource): - """ - This Resource provides a simple endpoint for flask-swagger to generate the API docs, - https://github.com/gangverk/flask-swagger - """ - def get(self): - """ - Generates Swagger UI readable JSON - --- - tags: - - system - definitions: - - schema: - id: GeoJsonPolygon - properties: - type: - type: string - default: Polygon - coordinates: - type: array - items: - type: number - default: [[-4.0237,56.0904],[-3.9111,56.1715],[-3.8122,56.0980],[-4.0237,56.0904]] - - schema: - id: GeoJsonMultiPolygon - properties: - type: - type: string - default: MultiPolygon - coordinates: - type: array - items: - type: number - default: [[[-4.0237,56.0904],[-3.9111,56.1715],[-3.8122,56.0980],[-4.0237,56.0904]]] - - schema: - id: ProjectInfo - properties: - locale: - type: string - default: en - name: - type: string - default: Thinkwhere Project - shortDescription: - type: string - default: Awesome little project - description: - type: string - default: Awesome little project and a little bit more - instructions: - type: string - default: Complete the tasks - perTaskInstructions: - type: string - default: Use Thinkwhere Imagery Only - - schema: - id: GeoJsonFeature +@router.get("/docs/json/", response_class=JSONResponse) +async def get_docs(request: Request): + """ + Generates Swagger UI readable JSON + --- + tags: + - system + definitions: + - schema: + id: GeoJsonPolygon + properties: + type: + type: string + default: Polygon + coordinates: + type: array + items: + type: number + default: [[-4.0237,56.0904],[-3.9111,56.1715],[-3.8122,56.0980],[-4.0237,56.0904]] + - schema: + id: GeoJsonMultiPolygon + properties: + type: + type: string + default: MultiPolygon + coordinates: + type: array + items: + type: number + default: [[[-4.0237,56.0904],[-3.9111,56.1715],[-3.8122,56.0980],[-4.0237,56.0904]]] + - schema: + id: ProjectInfo + properties: + locale: + type: string + default: en + name: + type: string + default: Thinkwhere Project + shortDescription: + type: string + default: Awesome little project + description: + type: string + default: Awesome little project and a little bit more + instructions: + type: string + default: Complete the tasks + perTaskInstructions: + type: string + default: Use Thinkwhere Imagery Only + - schema: + id: GeoJsonFeature + properties: + type: + type: string + default: Feature + geometry: + schema: + $ref: "#/definitions/GeoJsonMultiPolygon" properties: - type: - type: string - default: Feature - geometry: - schema: - $ref: "#/definitions/GeoJsonMultiPolygon" + type: object properties: - type: object - properties: - x: - type: integer - default: 2402 - y: - type: integer - default: 1736 - zoom: - type: integer - default: 12 - isSquare: - type: boolean - default: true - - schema: - id: ValidatedTask - properties: - taskId: - type: integer - default: 1 - status: - type: string - default: VALIDATED - comment: - type: string - default: Nice work :) - - schema: - id: ResetTask - properties: - taskId: - type: integer - default: 1 - comment: - type: string - default: Work in progress - - schema: - id: ProjectTeams - properties: - teamId: - type: integer - default: 1 - role: - type: string - default: MAPPER - - schema: - id: TeamMembers - properties: - userName: - type: string - default: user_1 - function: - type: string - default: MANAGER + x: + type: integer + default: 2402 + y: + type: integer + default: 1736 + zoom: + type: integer + default: 12 + isSquare: + type: boolean + default: true + - schema: + id: ValidatedTask + properties: + taskId: + type: integer + default: 1 + status: + type: string + default: VALIDATED + comment: + type: string + default: Nice work :) + - schema: + id: ResetTask + properties: + taskId: + type: integer + default: 1 + comment: + type: string + default: Work in progress + - schema: + id: ProjectTeams + properties: + teamId: + type: integer + default: 1 + role: + type: string + default: MAPPER + - schema: + id: TeamMembers + properties: + userName: + type: string + default: user_1 + function: + type: string + default: MANAGER - """ - swag = swagger(current_app) - swag["info"]["title"] = "Tasking Manager backend API" - swag["info"]["description"] = "API endpoints for the backend" - swag["info"]["version"] = "2.0.0" + """ + swag = request.app.openapi() + swag["info"]["title"] = "Tasking Manager backend API" + swag["info"]["description"] = "API endpoints for the backend" + swag["info"]["version"] = "2.0.0" - return jsonify(swag) + return JSONResponse(content=swag, status_code=200) -class SystemHeartbeatAPI(Resource): +@router.get("/languages/") +async def get(): """ - /api/health-check + Gets all supported languages + --- + tags: + - system + produces: + - application/json + responses: + 200: + description: Supported Languages + 500: + description: Internal Server Error """ - - def get(self): - """ - Simple health-check, if this is unreachable load balancers should be configures to raise an alert - --- - tags: - - system - produces: - - application/json - responses: - 200: - description: Service is Healthy - """ - release = ReleaseVersion.get() - if release is not None: - release = { - "version": release.tag_name, - "published_at": str(release.published_at), - } - return {"status": "healthy", "release": release}, 200 + languages = SettingsService.get_settings() + return languages.model_dump(by_alias=True) -class SystemLanguagesAPI(Resource): - def get(self): - """ - Gets all supported languages - --- - tags: - - system - produces: - - application/json - responses: - 200: - description: Supported Languages - 500: - description: Internal Server Error - """ - languages = SettingsService.get_settings() - return languages.to_primitive(), 200 +@router.get("/heartbeat/") +async def heartbeat(db: Database = Depends(get_db)): + """ + Simple health-check, if this is unreachable load balancers should be configured to raise an alert + --- + tags: + - system + produces: + - application/json + responses: + 200: + description: Service is Healthy + """ + query = """ + SELECT tag_name, published_at + FROM release_version + ORDER BY published_at DESC + LIMIT 1 + """ + release = await db.fetch_one(query) + if release: + release_info = { + "version": release["tag_name"], + "published_at": release["published_at"].isoformat(), + } + else: + release_info = None -class SystemContactAdminRestAPI(Resource): - def post(self): - """ - Send an email to the system admin - --- - tags: - - system - produces: - - application/json - parameters: - - in: body - name: body - required: true - description: JSON object with the data of the message to send to the system admin - schema: - properties: - name: - type: string - default: The name of the sender - email: - type: string - default: The email of the sender - content: - type: string - default: The content of the message - responses: - 201: - description: Email sent successfully - 400: - description: Invalid Request - 501: - description: Not Implemented - 500: - description: A problem occurred - """ - try: - data = request.get_json() - SMTPService.send_contact_admin_email(data) - return {"Success": "Email sent"}, 201 - except ValueError as e: - return {"Error": str(e), "SubCode": "NotImplemented"}, 501 + return {"status": "Fastapi healthy", "release": release_info} -class SystemReleaseAPI(Resource): - def post(self): - """ - Fetch latest release version form github and save to database. - --- - tags: - - system - produces: - - application/json - responses: - 201: - description: Saved version successfully to database - 502: - description: Couldn't fetch latest release from github - 500: - description: Internal server error - """ - response = requests.get( - "https://api.github.com/repos/hotosm/tasking-manager/releases/latest" +@router.post("/contact-admin/") +async def contact_admin(request: Request, data: dict = Body(...)): + """ + Send an email to the system admin + --- + tags: + - system + produces: + - application/json + parameters: + - in: body + name: body + required: true + description: JSON object with the data of the message to send to the system admin + schema: + properties: + name: + type: string + default: The name of the sender + email: + type: string + default: The email of the sender + content: + type: string + default: The content of the message + responses: + 201: + description: Email sent successfully + 400: + description: Invalid Request + 501: + description: Not Implemented + 500: + description: A problem occurred + """ + try: + await SMTPService.send_contact_admin_email(data) + return JSONResponse(content={"Success": "Email sent"}, status_code=201) + except ValueError as e: + return JSONResponse( + content={"Error": str(e), "SubCode": "NotImplemented"}, status_code=501 ) - try: - tag_name = response.json()["tag_name"] - published_date = response.json()["published_at"] - release = ReleaseVersion.get() - if release is None: - release = ReleaseVersion() - if tag_name != release.tag_name: - release.tag_name = tag_name - release.published_at = published_date - release.save() - return { + + +@router.post("/release/") +async def release(db: Database = Depends(get_db)): + """ + Fetch latest release version form github and save to database. + --- + tags: + - system + produces: + - application/json + responses: + 201: + description: Saved version successfully to database + 502: + description: Couldn't fetch latest release from github + 500: + description: Internal server error + """ + response = requests.get( + "https://api.github.com/repos/hotosm/tasking-manager/releases/latest" + ) + try: + tag_name = response.json()["tag_name"] + published_date = response.json()["published_at"] + published_date = datetime.strptime( + published_date, "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=None) + release = await ReleaseVersion.get(db) + if release: + release = ReleaseVersion(**release) + else: + release = ReleaseVersion() + if tag_name != release.tag_name: + release.tag_name = tag_name + release.published_at = published_date + await db.execute("""DELETE FROM release_version""") + await release.save(db) + return JSONResponse( + content={ "release_version": release.tag_name, "published_at": str(release.published_at), - }, 201 - except KeyError: - return { + }, + status_code=200, + ) + except KeyError: + return JSONResponse( + content={ "Error": "Couldn't fetch latest release from github", "SubCode": "GithubFetchError", - }, 502 + }, + status_code=502, + ) diff --git a/backend/api/system/image_upload.py b/backend/api/system/image_upload.py index 2dee13dcf9..9b08e5cf19 100644 --- a/backend/api/system/image_upload.py +++ b/backend/api/system/image_upload.py @@ -1,100 +1,115 @@ -import requests import json -from flask_restful import Resource, request, current_app +import requests +from fastapi import APIRouter, Body, Depends, Request +from fastapi.responses import JSONResponse + +from backend.config import settings +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.users.authentication_service import login_required -from backend.services.users.authentication_service import token_auth +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) -class SystemImageUploadRestAPI(Resource): - @token_auth.login_required - def post(self): - """ - Uploads an image using the image upload service - --- - tags: - - system - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object containing image data that will be uploaded - schema: - properties: - data: - type: string - default: base64 encoded image data - mime: - type: string - default: file mime/type - filename: - type: string - default: filename - responses: - 200: - description: Image uploaded successfully - 400: - description: Input parameter error - 403: - description: User is not authorized to upload images - 500: - description: A problem occurred - 501: - description: Image upload service not defined - """ - if ( - current_app.config["IMAGE_UPLOAD_API_URL"] is None - or current_app.config["IMAGE_UPLOAD_API_KEY"] is None - ): - return { +@router.post("/image-upload/") +async def post_image( + request: Request, + user: AuthUserDTO = Depends(login_required), + data: dict = Body(...), +): + """ + Uploads an image using the image upload service + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object containing image data that will be uploaded + schema: + properties: + data: + type: string + default: base64 encoded image data + mime: + type: string + default: file mime/type + filename: + type: string + default: filename + responses: + 200: + description: Image uploaded successfully + 400: + description: Input parameter error + 403: + description: User is not authorized to upload images + 500: + description: A problem occurred + 501: + description: Image upload service not defined + """ + if settings.IMAGE_UPLOAD_API_URL is None or settings.IMAGE_UPLOAD_API_KEY is None: + return JSONResponse( + content={ "Error": "Image upload service not defined", "SubCode": "UndefinedImageService", - }, 501 + }, + status_code=501, + ) - data = request.get_json() - if data.get("filename") is None: - return { + if data.get("filename") is None: + return JSONResponse( + content={ "Error": "Missing filename parameter", "SubCode": "MissingFilename", - }, 400 - if data.get("mime") in [ - "image/png", - "image/jpeg", - "image/webp", - "image/gif", - ]: - headers = { - "x-api-key": current_app.config["IMAGE_UPLOAD_API_KEY"], - "Content-Type": "application/json", - } - url = "{}?filename={}".format( - current_app.config["IMAGE_UPLOAD_API_URL"], data.get("filename") - ) - result = requests.post( - url, headers=headers, data=json.dumps({"image": data}) - ) - if result.ok: - return result.json(), 201 - else: - return result.json(), 400 - elif data.get("mime") is None: - return { + }, + status_code=400, + ) + if data.get("mime") in [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + ]: + headers = { + "x-api-key": settings.IMAGE_UPLOAD_API_KEY, + "Content-Type": "application/json", + } + url = "{}?filename={}".format( + settings.IMAGE_UPLOAD_API_URL, data.get("filename") + ) + result = requests.post(url, headers=headers, data=json.dumps({"image": data})) + if result.ok: + return JSONResponse(content=result.json(), status_code=201) + else: + return JSONResponse(content=result.json(), status_code=400) + elif data.get("mime") is None: + return JSONResponse( + content={ "Error": "Missing mime parameter", "SubCode": "MissingMime", - }, 400 - else: - return ( - { - "Error": "Mimetype is not allowed. The supported formats are: png, jpeg, webp and gif.", - "SubCode": "UnsupportedFile", - }, - 400, - ) + }, + status_code=400, + ) + else: + return JSONResponse( + content={ + "Error": "Mimetype is not allowed. The supported formats are: png, jpeg, webp and gif.", + "SubCode": "UnsupportedFile", + }, + status_code=400, + ) diff --git a/backend/api/system/statistics.py b/backend/api/system/statistics.py index 5ee43e418b..5c532ae599 100644 --- a/backend/api/system/statistics.py +++ b/backend/api/system/statistics.py @@ -1,35 +1,42 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Query + +from backend.db import get_db from backend.services.stats_service import StatsService -from flask_restful import request -from distutils.util import strtobool +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) -class SystemStatisticsAPI(Resource): - def get(self): - """ - Get HomePage Stats - --- - tags: - - system - produces: - - application/json - parameters: - - in: query - name: abbreviated - type: boolean - description: Set to false if complete details on projects including total area, campaigns, orgs are required - default: True - responses: - 200: - description: Project stats - 500: - description: Internal Server Error - """ - abbreviated = ( - strtobool(request.args.get("abbreviated")) - if request.args.get("abbreviated") - else True - ) - stats = StatsService.get_homepage_stats(abbreviated) - return stats.to_primitive(), 200 +@router.get("/statistics/") +async def get_statistics( + abbreviated: bool = Query( + default=True, + description="Set to false if complete details on projects including total area, campaigns, orgs are required", + ), + db: Database = Depends(get_db), +): + """ + Get HomePage Stats + --- + tags: + - system + produces: + - application/json + parameters: + - in: query + name: abbreviated + type: boolean + description: Set to false if complete details on projects including total area, campaigns, orgs are required + default: True + responses: + 200: + description: Project stats + 500: + description: Internal Server Error + """ + stats = await StatsService.get_homepage_stats(abbreviated, db) + return stats.model_dump(by_alias=True) diff --git a/backend/api/tasks/actions.py b/backend/api/tasks/actions.py index 10878ecffc..ae9c5856b5 100644 --- a/backend/api/tasks/actions.py +++ b/backend/api/tasks/actions.py @@ -1,1024 +1,1245 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request +from fastapi.responses import JSONResponse +from loguru import logger +from backend.db import get_db from backend.exceptions import NotFound from backend.models.dtos.grid_dto import SplitTaskDTO -from backend.models.postgis.utils import InvalidGeoJson -from backend.services.grid.split_service import SplitService, SplitServiceError -from backend.services.users.user_service import UserService -from backend.services.project_admin_service import ProjectAdminService -from backend.services.project_service import ProjectService -from backend.services.users.authentication_service import token_auth, tm +from backend.models.dtos.mapping_dto import ( + ExtendLockTimeDTO, + LockTaskDTO, + MappedTaskDTO, + StopMappingTaskDTO, +) +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.dtos.validator_dto import ( LockForValidationDTO, - UnlockAfterValidationDTO, - StopValidationDTO, RevertUserTasksDTO, + StopValidationDTO, + UnlockAfterValidationDTO, ) +from backend.models.postgis.utils import InvalidGeoJson +from backend.services.grid.split_service import SplitService, SplitServiceError +from backend.services.mapping_service import MappingService, MappingServiceError +from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_service import ProjectService +from backend.services.users.authentication_service import login_required +from backend.services.users.user_service import UserService from backend.services.validator_service import ( + UserLicenseError, ValidatorService, ValidatorServiceError, - UserLicenseError, ) -from backend.models.dtos.mapping_dto import ( - LockTaskDTO, - StopMappingTaskDTO, - MappedTaskDTO, - ExtendLockTimeDTO, -) -from backend.services.mapping_service import MappingService, MappingServiceError +router = APIRouter( + prefix="/projects", + tags=["tasks"], + responses={404: {"description": "Not found"}}, +) -class TasksActionsMappingLockAPI(Resource): - @token_auth.login_required - def post(self, project_id, task_id): - """ - Locks a task for mapping - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - responses: - 200: - description: Task locked - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 409: - description: User has not accepted license terms of project - 500: - description: Internal Server Error - """ - try: - lock_task_dto = LockTaskDTO() - lock_task_dto.user_id = token_auth.current_user() - lock_task_dto.project_id = project_id - lock_task_dto.task_id = task_id - lock_task_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - lock_task_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to lock task", "SubCode": "InvalidData"}, 400 - try: - ProjectService.exists(project_id) # Check if project exists - task = MappingService.lock_task_for_mapping(lock_task_dto) - return task.to_primitive(), 200 - except MappingServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - except UserLicenseError: - return { +@router.post("/{project_id}/tasks/actions/lock-for-mapping/{task_id}/") +async def lock_for_mapping( + request: Request, + project_id: int, + task_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Locks a task for mapping + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + responses: + 200: + description: Task locked + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 409: + description: User has not accepted license terms of project + 500: + description: Internal Server Error + """ + try: + lock_task_dto = LockTaskDTO( + user_id=user.id, + project_id=project_id, + task_id=task_id, + preferred_locale=request.headers.get("accept-language"), + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to lock task", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + task = await MappingService.lock_task_for_mapping(lock_task_dto, db) + return task + except MappingServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + except UserLicenseError: + return JSONResponse( + { "Error": "User not accepted license terms", "SubCode": "UserLicenseError", - }, 409 + }, + status_code=409, + ) -class TasksActionsMappingStopAPI(Resource): - @token_auth.login_required - def post(self, project_id, task_id): - """ - Unlock a task that is locked for mapping resetting it to its last status - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for unlocking a task - schema: - id: TaskUpdateStop - properties: - comment: - type: string - description: Optional user comment about the task - default: Comment about mapping done before stop - responses: - 200: - description: Task unlocked - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - stop_task = StopMappingTaskDTO( - request.get_json() if request.is_json else {} - ) - stop_task.user_id = token_auth.current_user() - stop_task.task_id = task_id - stop_task.project_id = project_id - stop_task.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - stop_task.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Task unlock failed", "SubCode": "InvalidData"}, 400 +@router.post("/{project_id}/tasks/actions/stop-mapping/{task_id}/") +async def stop_mapping( + request: Request, + project_id: int, + task_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Unlock a task that is locked for mapping resetting it to its last status + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for unlocking a task + schema: + id: TaskUpdateStop + properties: + comment: + type: string + description: Optional user comment about the task + default: Comment about mapping done before stop + responses: + 200: + description: Task unlocked + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + preferred_locale = request.headers.get("accept-language", None) + stop_task = StopMappingTaskDTO( + project_id=project_id, + task_id=task_id, + user_id=user.id, + comment=request_data.get("comment", None), + ) + if preferred_locale: + stop_task.preferred_locale = preferred_locale - try: - ProjectService.exists(project_id) # Check if project exists - task = MappingService.stop_mapping_task(stop_task) - return task.to_primitive(), 200 - except MappingServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Task unlock failed", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + task = await MappingService.stop_mapping_task(stop_task, db) + return task + except MappingServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class TasksActionsMappingUnlockAPI(Resource): - @token_auth.login_required - def post(self, project_id, task_id): - """ - Set a task as mapped - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for unlocking a task - schema: - id: TaskUpdateUnlock - required: - - status - properties: - status: - type: string - description: The new status for the task - default: MAPPED - comment: - type: string - description: Optional user comment about the task - default: Comment about the mapping - responses: - 200: - description: Task unlocked - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - mapped_task = MappedTaskDTO(request.get_json()) - mapped_task.user_id = authenticated_user_id - mapped_task.task_id = task_id - mapped_task.project_id = project_id - mapped_task.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Task unlock failed", "SubCode": "InvalidData"}, 400 +@router.post("/{project_id}/tasks/actions/unlock-after-mapping/{task_id}/") +async def unlock_after_mapping( + request: Request, + project_id: int, + task_id: int, + background_tasks: BackgroundTasks, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Set a task as mapped + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for unlocking a task + schema: + id: TaskUpdateUnlock + required: + - status + properties: + status: + type: string + description: The new status for the task + default: MAPPED + comment: + type: string + description: Optional user comment about the task + default: Comment about the mapping + responses: + 200: + description: Task unlocked + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + mapped_task = MappedTaskDTO( + user_id=user.id, + project_id=project_id, + task_id=task_id, + status=request_data.get("status"), + comment=request_data.get("comment", None), + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Task unlock failed", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + task = await MappingService.unlock_task_after_mapping( + mapped_task, db, background_tasks + ) + return task - try: - ProjectService.exists(project_id) # Check if project exists - task = MappingService.unlock_task_after_mapping(mapped_task) - return task.to_primitive(), 200 - except MappingServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - except NotFound as e: - return e.to_dict() - except Exception as e: - error_msg = f"Task Lock API - unhandled error: {str(e)}" - current_app.logger.critical(error_msg) - return { - "Error": "Task unlock failed", - "SubCode": "InternalServerError", - }, 500 - finally: - # Refresh mapper level after mapping - UserService.check_and_update_mapper_level(authenticated_user_id) + except MappingServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + except NotFound as e: + return JSONResponse( + content=e.to_dict(), + status_code=404, + ) + except Exception as e: + logger.critical(f"Task Unlock API - unhandled error: {str(e)}") + return JSONResponse( + content={"Error": "Task unlock failed", "SubCode": "InternalServerError"}, + status_code=500, + ) + finally: + await UserService.check_and_update_mapper_level(user.id, db) -class TasksActionsMappingUndoAPI(Resource): - @token_auth.login_required - def post(self, project_id, task_id): - """ - Undo a task's mapping status - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - responses: - 200: - description: Task found - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - ProjectService.exists(project_id) # Check if project exists - task = MappingService.undo_mapping( - project_id, task_id, token_auth.current_user(), preferred_locale - ) - return task.to_primitive(), 200 - except MappingServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/tasks/actions/undo-last-action/{task_id}/") +async def undo_last_action( + request: Request, + project_id: int, + task_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Undo a task's mapping status + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + responses: + 200: + description: Task found + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + preferred_locale = request.headers.get("accept-language", None) + await ProjectService.exists(project_id, db) + async with db.transaction(): + if preferred_locale: + task = await MappingService.undo_mapping( + project_id, task_id, user.id, db, preferred_locale + ) + else: + task = await MappingService.undo_mapping( + project_id, task_id, user.id, db + ) + return task + except MappingServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class TasksActionsValidationLockAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Lock tasks for validation - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the tasks are associated with - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for locking task(s) - schema: - properties: - taskIds: - type: array - items: - type: integer - description: Array of taskIds for locking - default: [1,2] - responses: - 200: - description: Task(s) locked for validation - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 409: - description: User has not accepted license terms of project - 500: - description: Internal Server Error - """ - try: - validator_dto = LockForValidationDTO(request.get_json()) - validator_dto.project_id = project_id - validator_dto.user_id = token_auth.current_user() - validator_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - validator_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to lock task", "SubCode": "InvalidData"}, 400 +@router.post("/{project_id}/tasks/actions/lock-for-validation/") +async def lock_for_validation( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Lock tasks for validation + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the tasks are associated with + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for locking task(s) + schema: + properties: + taskIds: + type: array + items: + type: integer + description: Array of taskIds for locking + default: [1,2] + responses: + 200: + description: Task(s) locked for validation + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 409: + description: User has not accepted license terms of project + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + task_ids = request_data.get("taskIds") + preferred_locale = request.headers.get("accept-language", None) + validator_dto = LockForValidationDTO( + project_id=project_id, task_ids=task_ids, user_id=user.id + ) + if preferred_locale: + validator_dto.preferred_locale = preferred_locale + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to lock task", "SubCode": "InvalidData"}, + status_code=400, + ) - try: - ProjectService.exists(project_id) # Check if project exists - tasks = ValidatorService.lock_tasks_for_validation(validator_dto) - return tasks.to_primitive(), 200 - except ValidatorServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - except UserLicenseError: - return { + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + tasks = await ValidatorService.lock_tasks_for_validation(validator_dto, db) + return tasks + except ValidatorServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + except UserLicenseError: + return JSONResponse( + content={ "Error": "User not accepted license terms", "SubCode": "UserLicenseError", - }, 409 - - -class TasksActionsValidationStopAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def post(self, project_id): - """ - Unlock tasks that are locked for validation resetting them to their last status - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for unlocking a task - schema: - properties: - resetTasks: - type: array - items: - schema: - $ref: "#/definitions/ResetTask" - responses: - 200: - description: Task unlocked - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - validated_dto = StopValidationDTO(request.get_json()) - validated_dto.project_id = project_id - validated_dto.user_id = token_auth.current_user() - validated_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - validated_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Task unlock failed", "SubCode": "InvalidData"}, 400 + }, + status_code=409, + ) - try: - ProjectService.exists(project_id) # Check if project exists - tasks = ValidatorService.stop_validating_tasks(validated_dto) - return tasks.to_primitive(), 200 - except ValidatorServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/tasks/actions/stop-validation/") +async def stop_validation( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Unlock tasks that are locked for validation resetting them to their last status + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for unlocking a task + schema: + properties: + resetTasks: + type: array + items: + schema: + $ref: "#/definitions/ResetTask" + responses: + 200: + description: Task unlocked + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + reset_tasks = request_data.get("resetTasks") + preferred_locale = request.headers.get("accept-language", None) + validated_dto = StopValidationDTO( + project_id=project_id, user_id=user.id, reset_tasks=reset_tasks + ) + if preferred_locale: + validated_dto.preferred_locale = preferred_locale + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Task unlock failed", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + tasks = await ValidatorService.stop_validating_tasks(validated_dto, db) + return tasks + except ValidatorServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class TasksActionsValidationUnlockAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Set tasks as validated - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for unlocking a task - schema: - properties: - validatedTasks: - type: array - items: - schema: - $ref: "#/definitions/ValidatedTask" - responses: - 200: - description: Task unlocked - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - validated_dto = UnlockAfterValidationDTO(request.get_json()) - validated_dto.project_id = project_id - validated_dto.user_id = token_auth.current_user() - validated_dto.preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - validated_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Task unlock failed", "SubCode": "InvalidData"}, 400 - try: - ProjectService.exists(project_id) # Check if project exists - tasks = ValidatorService.unlock_tasks_after_validation(validated_dto) - return tasks.to_primitive(), 200 - except ValidatorServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/tasks/actions/unlock-after-validation/") +async def unlock_after_validation( + request: Request, + project_id: int, + background_tasks: BackgroundTasks, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Set tasks as validated + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for unlocking a task + schema: + properties: + validatedTasks: + type: array + items: + schema: + $ref: "#/definitions/ValidatedTask" + responses: + 200: + description: Task unlocked + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + validated_tasks = request_data.get("validatedTasks") + preferred_locale = request.headers.get("accept-language", None) + validated_dto = UnlockAfterValidationDTO( + project_id=project_id, user_id=user.id, validated_tasks=validated_tasks + ) + if preferred_locale: + validated_dto.preferred_locale = preferred_locale + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Task unlock failed", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + tasks = await ValidatorService.unlock_tasks_after_validation( + validated_dto, db, background_tasks + ) + return tasks + except ValidatorServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class TasksActionsMapAllAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Map all tasks on a project - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: All tasks mapped - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/tasks/actions/map-all/") +async def map_all( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Map all tasks on a project + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: All tasks mapped + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + authenticated_user_id = user.id + if not await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - MappingService.map_all_tasks(project_id, authenticated_user_id) - return {"Success": "All tasks mapped"}, 200 + async with db.transaction(): + await MappingService.map_all_tasks(project_id, authenticated_user_id, db) + return JSONResponse(content={"Success": "All tasks mapped"}, status_code=200) -class TasksActionsValidateAllAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Validate all mapped tasks on a project - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: All mapped tasks validated - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/tasks/actions/validate-all/") +async def post( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Validate all mapped tasks on a project + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: All mapped tasks validated + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + authenticated_user_id = user.id + if not await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) - ValidatorService.validate_all_tasks(project_id, authenticated_user_id) - return {"Success": "All tasks validated"}, 200 + async with db.transaction(): + await ValidatorService.validate_all_tasks(project_id, authenticated_user_id, db) + return JSONResponse(content={"Success": "All tasks validated"}, status_code=200) -class TasksActionsInvalidateAllAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Invalidate all validated tasks on a project - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: All validated tasks invalidated - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/tasks/actions/invalidate-all/") +async def invalidate_all( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Invalidate all validated tasks on a project + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: All validated tasks invalidated + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + authenticated_user_id = user.id + if not await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) + async with db.transaction(): + await ValidatorService.invalidate_all_tasks( + project_id, authenticated_user_id, db + ) + return JSONResponse( + content={"Success": "All tasks invalidated"}, status_code=200 + ) - ValidatorService.invalidate_all_tasks(project_id, authenticated_user_id) - return {"Success": "All tasks invalidated"}, 200 - -class TasksActionsResetBadImageryAllAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Set all bad imagery tasks as ready for mapping - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: All bad imagery tasks marked ready for mapping - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/tasks/actions/reset-all-badimagery/") +async def reset_all_bad_imagery( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Set all bad imagery tasks as ready for mapping + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: All bad imagery tasks marked ready for mapping + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + authenticated_user_id = user.id + if not await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 - - MappingService.reset_all_badimagery(project_id, authenticated_user_id) - return {"Success": "All bad imagery tasks marked ready for mapping"}, 200 + }, + status_code=403, + ) + async with db.transaction(): + await MappingService.reset_all_badimagery(project_id, authenticated_user_id, db) + return JSONResponse( + content={"Success": "All bad imagery tasks marked ready for mapping"}, + status_code=200, + ) -class TasksActionsResetAllAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Reset all tasks on project back to ready, preserving history - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: All tasks reset - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - authenticated_user_id = token_auth.current_user() - if not ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id - ): - raise ValueError() - except ValueError: - return { +@router.post("/{project_id}/tasks/actions/reset-all/") +async def reset_all( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Reset all tasks on project back to ready, preserving history + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: All tasks reset + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + authenticated_user_id = user.id + if not await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ): + raise ValueError() + except ValueError: + return JSONResponse( + content={ "Error": "User is not a manager of the project", "SubCode": "UserPermissionError", - }, 403 + }, + status_code=403, + ) + async with db.transaction(): + await ProjectAdminService.reset_all_tasks(project_id, authenticated_user_id, db) + return JSONResponse(content={"Success": "All tasks reset"}, status_code=200) - ProjectAdminService.reset_all_tasks(project_id, authenticated_user_id) - return {"Success": "All tasks reset"}, 200 - -class TasksActionsSplitAPI(Resource): - @token_auth.login_required - def post(self, project_id, task_id): - """ - Split a task - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - responses: - 200: - description: Task split OK - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - split_task_dto = SplitTaskDTO() - split_task_dto.user_id = token_auth.current_user() - split_task_dto.project_id = project_id - split_task_dto.task_id = task_id - split_task_dto.preferred_locale = request.environ.get( - "HTTP_ACCEPT_LANGUAGE" - ) - split_task_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to split task", "SubCode": "InvalidData"}, 400 - try: - ProjectService.exists(project_id) # Check if project exists - tasks = SplitService.split_task(split_task_dto) - return tasks.to_primitive(), 200 - except SplitServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 - except InvalidGeoJson as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/tasks/actions/split/{task_id}/") +async def split_task( + request: Request, + project_id: int, + task_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Split a task + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + responses: + 200: + description: Task split OK + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + preferred_locale = request.headers.get("accept-language", None) + split_task_dto = SplitTaskDTO( + user_id=user.id, project_id=project_id, task_id=task_id + ) + if preferred_locale: + split_task_dto.preferred_locale = preferred_locale + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to split task", "SubCode": "InvalidData"}, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + tasks = await SplitService.split_task(split_task_dto, db) + return tasks + except SplitServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) + except InvalidGeoJson as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class TasksActionsExtendAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Extends duration of locked tasks - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the tasks are associated with - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for locking task(s) - schema: - properties: - taskIds: - type: array - items: - type: integer - description: Array of taskIds to extend time for - default: [1,2] - responses: - 200: - description: Task(s) locked for validation - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - extend_dto = ExtendLockTimeDTO(request.get_json()) - extend_dto.project_id = project_id - extend_dto.user_id = token_auth.current_user() - extend_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { +@router.post("/{project_id}/tasks/actions/extend/") +async def extend_duration( + request: Request, + project_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Extends duration of locked tasks + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the tasks are associated with + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for locking task(s) + schema: + properties: + taskIds: + type: array + items: + type: integer + description: Array of taskIds to extend time for + default: [1,2] + responses: + 200: + description: Task(s) locked for validation + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + request_data = await request.json() + task_ids = request_data.get("taskIds", None) + extend_dto = ExtendLockTimeDTO( + project_id=project_id, task_ids=task_ids, user_id=user.id + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to extend lock time", "SubCode": "InvalidData", - }, 400 + }, + status_code=400, + ) + try: + await ProjectService.exists(project_id, db) + async with db.transaction(): + await MappingService.extend_task_lock_time(extend_dto, db) + return JSONResponse( + content={"Success": "Successfully extended task expiry"}, + status_code=200, + ) + except MappingServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) - try: - ProjectService.exists(project_id) # Check if project exists - MappingService.extend_task_lock_time(extend_dto) - return {"Success": "Successfully extended task expiry"}, 200 - except MappingServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{project_id}/tasks/actions/reset-by-user/") +async def reset_by_user( + request: Request, + project_id: int, + username: str | None = Query( + None, description="Username to revert tasks for", example="test" + ), + action: str | None = Query( + None, + description="Action to revert tasks for. Can be BADIMAGERY or VALIDATED", + example="BADIMAGERY", + ), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Revert tasks by a specific user in a project + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token session + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - in: path + name: project_id + description: Project ID the tasks are associated with + required: true + type: integer + default: 1 + - in: query + name: username + description: Username to revert tasks for + required: true + type: string + default: test + - in: query + name: action + description: Action to revert tasks for. Can be BADIMAGERY or VALIDATED + required: true + type: string + responses: + 200: + description: Tasks reverted + 400: + description: Client Error + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Task not found + 500: + description: Internal Server Error + """ + try: + if not (username or action): + return JSONResponse( + content={ + "Error": "Unable to revert tasks", + "SubCode": "InvalidData", + }, + status_code=400, + ) -class TasksActionsReverUserTaskstAPI(Resource): - @token_auth.login_required - def post(self, project_id): - """ - Revert tasks by a specific user in a project - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token session - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - in: path - name: project_id - description: Project ID the tasks are associated with - required: true - type: integer - default: 1 - - in: query - name: username - description: Username to revert tasks for - required: true - type: string - default: test - - in: query - name: action - description: Action to revert tasks for. Can be BADIMAGERY or VALIDATED - required: true - type: string - responses: - 200: - description: Tasks reverted - 400: - description: Client Error - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Task not found - 500: - description: Internal Server Error - """ - try: - revert_dto = RevertUserTasksDTO() - revert_dto.project_id = project_id - revert_dto.action = request.args.get("action") - user = UserService.get_user_by_username(request.args.get("username")) - revert_dto.user_id = user.id - revert_dto.action_by = token_auth.current_user() - revert_dto.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { + if username: + revert_user = await UserService.get_user_by_username(username, db) + revert_dto = RevertUserTasksDTO( + project_id=project_id, + user_id=revert_user.id, + action_by=user.id, + action=action, + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to revert tasks", "SubCode": "InvalidData", - }, 400 - try: - ValidatorService.revert_user_tasks(revert_dto) - return {"Success": "Successfully reverted tasks"}, 200 - except ValidatorServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 + }, + status_code=400, + ) + try: + async with db.transaction(): + await ValidatorService.revert_user_tasks(revert_dto, db) + return JSONResponse( + content={"Success": "Successfully reverted tasks"}, status_code=200 + ) + except ValidatorServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) diff --git a/backend/api/tasks/resources.py b/backend/api/tasks/resources.py index 90a04905cd..bc72ad9309 100644 --- a/backend/api/tasks/resources.py +++ b/backend/api/tasks/resources.py @@ -1,497 +1,505 @@ import io -from distutils.util import strtobool +import json -from flask import send_file, Response -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import JSONResponse, Response, StreamingResponse +from loguru import logger +from starlette.authentication import requires -from backend.services.mapping_service import MappingService +from backend.db import get_db from backend.models.dtos.grid_dto import GridDTO - -from backend.services.users.authentication_service import token_auth, tm -from backend.services.users.user_service import UserService -from backend.services.validator_service import ValidatorService - -from backend.services.project_service import ProjectService, ProjectServiceError -from backend.services.grid.grid_service import GridService from backend.models.postgis.statuses import UserRole from backend.models.postgis.utils import InvalidGeoJson +from backend.services.grid.grid_service import GridService +from backend.services.mapping_service import MappingService +from backend.services.project_service import ProjectService, ProjectServiceError +from backend.services.users.authentication_service import tm +from backend.services.users.user_service import UserService +from backend.services.validator_service import ValidatorService - -class TasksRestAPI(Resource): - def get(self, project_id, task_id): - """ - Get a task's metadata - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - name: task_id - in: path - description: Unique task ID - required: true - type: integer - default: 1 - responses: - 200: - description: Task found - 404: - description: Task not found - 500: - description: Internal Server Error - """ - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - - task = MappingService.get_task_as_dto(task_id, project_id, preferred_locale) - return task.to_primitive(), 200 - - -class TasksQueriesJsonAPI(Resource): - def get(self, project_id): - """ - Get all tasks for a project as JSON - --- - tags: - - tasks - produces: - - application/json - parameters: - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: query - name: tasks - type: string - description: List of tasks; leave blank to retrieve all - default: 1,2 - - in: query - name: as_file - type: boolean - description: Set to true if file download preferred - default: True - responses: - 200: - description: Project found - 403: - description: Forbidden - 404: - description: Project not found - 500: - description: Internal Server Error - """ - try: - tasks = request.args.get("tasks") if request.args.get("tasks") else None - as_file = ( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else True - ) - - tasks_json = ProjectService.get_project_tasks(int(project_id), tasks) - - if as_file: - tasks_json = str(tasks_json).encode("utf-8") - return send_file( - io.BytesIO(tasks_json), - mimetype="application/json", - as_attachment=True, - download_name=f"{str(project_id)}-tasks.geojson", - ) - - return tasks_json, 200 - except ProjectServiceError as e: - return {"Error": str(e)}, 403 - - @token_auth.login_required - def delete(self, project_id): - """ - Delete a list of tasks from a project - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object with a list of tasks to delete - schema: - properties: - tasks: - type: array - items: - type: integer - default: [ 1, 2 ] - responses: - 200: - description: Task(s) deleted - 400: - description: Bad request - 403: - description: Forbidden - 404: - description: Project or Task Not Found - 500: - description: Internal Server Error - """ - user_id = token_auth.current_user() - user = UserService.get_user_by_id(user_id) - if user.role != UserRole.ADMIN.value: - return { - "Error": "This endpoint action is restricted to ADMIN users.", - "SubCode": "OnlyAdminAccess", - }, 403 - - tasks_ids = request.get_json().get("tasks") - if tasks_ids is None: - return {"Error": "Tasks ids not provided", "SubCode": "InvalidData"}, 400 - if isinstance(tasks_ids, list) is False: - return { - "Error": "Tasks were not provided as a list", - "SubCode": "InvalidData", - }, 400 - - try: - ProjectService.delete_tasks(project_id, tasks_ids) - return {"Success": "Task(s) deleted"}, 200 - except ProjectServiceError as e: - return {"Error": str(e)}, 403 - - -class TasksQueriesXmlAPI(Resource): - def get(self, project_id): - """ - Get all tasks for a project as OSM XML - --- - tags: - - tasks - produces: - - application/xml - parameters: - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: query - name: tasks - type: string - description: List of tasks; leave blank to retrieve all - default: 1,2 - - in: query - name: as_file - type: boolean - description: Set to true if file download preferred - default: False - responses: - 200: - description: OSM XML - 400: - description: Client Error - 404: - description: No mapped tasks - 500: - description: Internal Server Error - """ - tasks = request.args.get("tasks") if request.args.get("tasks") else None - as_file = ( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else False - ) - - xml = MappingService.generate_osm_xml(project_id, tasks) +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/{project_id}/tasks/{task_id}/") +async def retrieve_task( + request: Request, project_id: int, task_id: int, db: Database = Depends(get_db) +): + """ + Get a task's metadata + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - name: task_id + in: path + description: Unique task ID + required: true + type: integer + default: 1 + responses: + 200: + description: Task found + 404: + description: Task not found + 500: + description: Internal Server Error + """ + preferred_locale = request.headers.get("accept-language") + task = await MappingService.get_task_as_dto( + task_id, project_id, db, preferred_locale + ) + return task + + +@router.get("/{project_id}/tasks/") +async def get_project_tasks( + project_id: int, + tasks: str = Query(default=None), + as_file: bool = Query(default=False, alias="as_file"), + db: Database = Depends(get_db), +): + """ + Get all tasks for a project as JSON + --- + tags: + - tasks + produces: + - application/json + parameters: + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: query + name: tasks + type: string + description: List of tasks; leave blank to retrieve all + default: 1,2 + - in: query + name: as_file + type: boolean + description: Set to true if file download preferred + default: True + responses: + 200: + description: Project found + 403: + description: Forbidden + 404: + description: Project not found + 500: + description: Internal Server Error + """ + try: + tasks_json = await ProjectService.get_project_tasks(db, project_id, tasks) if as_file: - return send_file( - io.BytesIO(xml), - mimetype="text.xml", - as_attachment=True, - download_name=f"HOT-project-{project_id}.osm", + tasks_str = json.dumps(tasks_json, indent=4) + return Response( + content=tasks_str, + media_type="application/geo+json", + headers={ + "Content-Disposition": f'attachment; filename="{project_id}-tasks.geojson"' + }, ) - return Response(xml, mimetype="text/xml", status=200) - - -class TasksQueriesGpxAPI(Resource): - def get(self, project_id): - """ - Get all tasks for a project as GPX - --- - tags: - - tasks - produces: - - application/xml - parameters: - - name: project_id - in: path - description: Project ID the task is associated with - required: true - type: integer - default: 1 - - in: query - name: tasks - type: string - description: List of tasks; leave blank for all - default: 1,2 - - in: query - name: as_file - type: boolean - description: Set to true if file download preferred - default: False - responses: - 200: - description: GPX XML - 400: - description: Client error - 404: - description: No mapped tasks - 500: - description: Internal Server Error - """ - current_app.logger.debug("GPX Called") - tasks = request.args.get("tasks") - as_file = ( - strtobool(request.args.get("as_file")) - if request.args.get("as_file") - else False + return tasks_json + + except ProjectServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=403) + + +@router.delete("/{project_id}/tasks/") +@requires("authenticated") +async def delete(request: Request, project_id): + """ + Delete a list of tasks from a project + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object with a list of tasks to delete + schema: + properties: + tasks: + type: array + items: + type: integer + default: [ 1, 2 ] + responses: + 200: + description: Task(s) deleted + 400: + description: Bad request + 403: + description: Forbidden + 404: + description: Project or Task Not Found + 500: + description: Internal Server Error + """ + user_id = request.user.display_name + user = UserService.get_user_by_id(user_id) + if user.role != UserRole.ADMIN.value: + return { + "Error": "This endpoint action is restricted to ADMIN users.", + "SubCode": "OnlyAdminAccess", + }, 403 + + tasks_ids = await request.json().get("tasks") + if tasks_ids is None: + return {"Error": "Tasks ids not provided", "SubCode": "InvalidData"}, 400 + if isinstance(tasks_ids, list) is False: + return { + "Error": "Tasks were not provided as a list", + "SubCode": "InvalidData", + }, 400 + + try: + ProjectService.delete_tasks(project_id, tasks_ids) + return {"Success": "Task(s) deleted"}, 200 + except ProjectServiceError as e: + return {"Error": str(e)}, 403 + + +@router.get("/{project_id}/tasks/queries/xml/") +async def get_tasks_xml( + project_id: int, + tasks: str = Query(default=None), + as_file: bool = Query(default=False, alias="as_file"), + db: Database = Depends(get_db), +): + """ + Get all tasks for a project as OSM XML + --- + tags: + - tasks + produces: + - application/xml + parameters: + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: query + name: tasks + type: string + description: List of tasks; leave blank to retrieve all + default: 1,2 + - in: query + name: as_file + type: boolean + description: Set to true if file download preferred + default: False + responses: + 200: + description: OSM XML + 400: + description: Client Error + 404: + description: No mapped tasks + 500: + description: Internal Server Error + """ + xml = await MappingService.generate_osm_xml(project_id, tasks, db) + + if as_file: + return StreamingResponse( + io.BytesIO(xml), + media_type="text/xml", + headers={ + "Content-Disposition": f"attachment; filename=HOT-project-{project_id}.osm" + }, ) - xml = MappingService.generate_gpx(project_id, tasks) - - if as_file: - return send_file( - io.BytesIO(xml), - mimetype="text.xml", - as_attachment=True, - download_name=f"HOT-project-{project_id}.gpx", - ) - - return Response(xml, mimetype="text/xml", status=200) - - -class TasksQueriesAoiAPI(Resource): - @tm.pm_only() - @token_auth.login_required - def put(self): - """ - Get task tiles intersecting with the aoi provided - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object containing aoi and tasks and bool flag for controlling clip grid to aoi - schema: - properties: - clipToAoi: - type: boolean - default: true - areaOfInterest: - schema: - properties: - type: - type: string - default: FeatureCollection - features: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonFeature" - grid: - schema: - properties: - type: - type: string - default: FeatureCollection - features: - type: array - items: - schema: - $ref: "#/definitions/GeoJsonFeature" - responses: - 200: - description: Intersecting tasks found successfully - 400: - description: Client Error - Invalid Request - 500: - description: Internal Server Error - """ - try: - grid_dto = GridDTO(request.get_json()) - grid_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return { - "Error": "Unable to fetch tiles interesecting AOI", - "SubCode": "InvalidData", - }, 400 - - try: - grid = GridService.trim_grid_to_aoi(grid_dto) - return grid, 200 - except InvalidGeoJson as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 - - -class TasksQueriesMappedAPI(Resource): - def get(self, project_id): - """ - Get all mapped tasks for a project grouped by username - --- - tags: - - tasks - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: Mapped tasks returned - 500: - description: Internal Server Error - """ - ProjectService.get_project_by_id(project_id) - mapped_tasks = ValidatorService.get_mapped_tasks_by_user(project_id) - return mapped_tasks.to_primitive(), 200 - - -class TasksQueriesOwnInvalidatedAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def get(self, username): - """ - Get invalidated tasks either mapped by user or invalidated by user - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - name: username - in: path - description: The users username - required: true - type: string - - in: query - name: asValidator - description: treats user as validator, rather than mapper, if true - type: string - - in: query - name: sortBy - description: field to sort by, defaults to action_date - type: string - - in: query - name: sortDirection - description: direction of sort, defaults to desc - type: string - - in: query - name: page - description: Page of results user requested - type: integer - - in: query - name: pageSize - description: Size of page, defaults to 10 - type: integer - - in: query - name: project - description: Optional project filter - type: integer - - in: query - name: closed - description: Optional filter for open/closed invalidations - type: boolean - responses: - 200: - description: Invalidated tasks user has invalidated - 404: - description: No invalidated tasks - 500: - description: Internal Server Error - """ - sort_column = {"updatedDate": "updated_date", "projectId": "project_id"} - if request.args.get("sortBy", "updatedDate") in sort_column: - sort_column = sort_column[request.args.get("sortBy", "updatedDate")] - else: - sort_column = sort_column["updatedDate"] - # closed needs to be set to True, False, or None - closed = None - if request.args.get("closed") == "true": - closed = True - elif request.args.get("closed") == "false": - closed = False - # sort direction should only be desc or asc - if request.args.get("sortDirection") in ["asc", "desc"]: - sort_direction = request.args.get("sortDirection") - else: - sort_direction = "desc" - invalidated_tasks = ValidatorService.get_user_invalidated_tasks( - request.args.get("asValidator") == "true", - username, - request.environ.get("HTTP_ACCEPT_LANGUAGE"), - closed, - request.args.get("project", None, type=int), - request.args.get("page", None, type=int), - request.args.get("pageSize", None, type=int), - sort_column, - sort_direction, + return Response(content=xml, media_type="text/xml", status_code=200) + + +@router.get("/{project_id}/tasks/queries/gpx/") +async def get_tasks_gpx( + project_id: int, + tasks: str = Query(default=None), + as_file: bool = Query(default=False, alias="as_file"), + db: Database = Depends(get_db), +): + """ + Get all tasks for a project as GPX + --- + tags: + - tasks + produces: + - application/xml + parameters: + - name: project_id + in: path + description: Project ID the task is associated with + required: true + type: integer + default: 1 + - in: query + name: tasks + type: string + description: List of tasks; leave blank for all + default: 1,2 + - in: query + name: as_file + type: boolean + description: Set to true if file download preferred + default: False + responses: + 200: + description: GPX XML + 400: + description: Client error + 404: + description: No mapped tasks + 500: + description: Internal Server Error + """ + xml = await MappingService.generate_gpx(project_id, tasks, db) + + if as_file: + return StreamingResponse( + io.BytesIO(xml), + media_type="text/xml", + headers={ + "Content-Disposition": f"attachment; filename=HOT-project-{project_id}.gpx" + }, ) - return invalidated_tasks.to_primitive(), 200 + + return Response(content=xml, media_type="text/xml", status_code=200) + + +@router.put("/{project_id}/tasks/queries/aoi/") +@requires("authenticated") +@tm.pm_only() +async def tasks_aoi(request: Request, project_id: int): + """ + Get task tiles intersecting with the aoi provided + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object containing aoi and tasks and bool flag for controlling clip grid to aoi + schema: + properties: + clipToAoi: + type: boolean + default: true + areaOfInterest: + schema: + properties: + type: + type: string + default: FeatureCollection + features: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonFeature" + grid: + schema: + properties: + type: + type: string + default: FeatureCollection + features: + type: array + items: + schema: + $ref: "#/definitions/GeoJsonFeature" + responses: + 200: + description: Intersecting tasks found successfully + 400: + description: Client Error - Invalid Request + 500: + description: Internal Server Error + """ + try: + grid_dto = GridDTO(request.get_json()) + grid_dto.validate() + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return { + "Error": "Unable to fetch tiles interesecting AOI", + "SubCode": "InvalidData", + }, 400 + + try: + grid = GridService.trim_grid_to_aoi(grid_dto) + return grid, 200 + except InvalidGeoJson as e: + return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 + + +@router.get("/{project_id}/tasks/queries/mapped/") +async def get_mapped_tasks(project_id: int, db: Database = Depends(get_db)): + """ + Get all mapped tasks for a project grouped by username + --- + tags: + - tasks + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: Mapped tasks returned + 500: + description: Internal Server Error + """ + await ProjectService.get_project_by_id(project_id, db) + mapped_tasks = await ValidatorService.get_mapped_tasks_by_user(project_id, db) + return mapped_tasks.model_dump(by_alias=True) + + +@router.get("/{username}/tasks/queries/own/invalidated/") +@requires("authenticated") +async def get_invalidated_tasks(request: Request, username: str): + """ + Get invalidated tasks either mapped by user or invalidated by user + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - name: username + in: path + description: The users username + required: true + type: string + - in: query + name: asValidator + description: treats user as validator, rather than mapper, if true + type: string + - in: query + name: sortBy + description: field to sort by, defaults to action_date + type: string + - in: query + name: sortDirection + description: direction of sort, defaults to desc + type: string + - in: query + name: page + description: Page of results user requested + type: integer + - in: query + name: pageSize + description: Size of page, defaults to 10 + type: integer + - in: query + name: project + description: Optional project filter + type: integer + - in: query + name: closed + description: Optional filter for open/closed invalidations + type: boolean + responses: + 200: + description: Invalidated tasks user has invalidated + 404: + description: No invalidated tasks + 500: + description: Internal Server Error + """ + sort_column = {"updatedDate": "updated_date", "projectId": "project_id"} + if request.query_params.get("sortBy", "updatedDate") in sort_column: + sort_column = sort_column[request.query_params.get("sortBy", "updatedDate")] + else: + sort_column = sort_column["updatedDate"] + # closed needs to be set to True, False, or None + closed = None + if request.query_params.get("closed") == "true": + closed = True + elif request.query_params.get("closed") == "false": + closed = False + # sort direction should only be desc or asc + if request.query_params.get("sortDirection") in ["asc", "desc"]: + sort_direction = request.query_params.get("sortDirection") + else: + sort_direction = "desc" + invalidated_tasks = ValidatorService.get_user_invalidated_tasks( + request.query_params.get("asValidator") == "true", + username, + request.environ.get("HTTP_ACCEPT_LANGUAGE"), + closed, + request.query_params.get("project", None), + request.query_params.get("page", None), + request.query_params.get("pageSize", None), + sort_column, + sort_direction, + ) + return invalidated_tasks.model_dump(by_alias=True), 200 diff --git a/backend/api/tasks/statistics.py b/backend/api/tasks/statistics.py index f51b5a00cb..3ad665b06f 100644 --- a/backend/api/tasks/statistics.py +++ b/backend/api/tasks/statistics.py @@ -1,101 +1,117 @@ from datetime import date, timedelta -from flask_restful import Resource, request -from backend.services.users.authentication_service import token_auth -from backend.services.stats_service import StatsService +from databases import Database +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse + from backend.api.utils import validate_date_input +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.stats_service import StatsService +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/tasks", + tags=["tasks"], + responses={404: {"description": "Not found"}}, +) + +@router.get("/statistics/") +async def get_task_stats( + start_date_str: str = Query(..., alias="startDate"), + end_date_str: str = Query(default=str(date.today()), alias="endDate"), + organisation_id: str = Query(default=None, alias="organisationId"), + organisation_name: str = Query(default=None, alias="organisationName"), + campaign: str = Query(default=None), + project_id: str = Query(default=None, alias="projectId"), + country: str = Query(default=None), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get Task Stats + --- + tags: + - tasks + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: query + name: startDate + description: Date to filter as minimum + required: true + type: string + - in: query + name: endDate + description: Date to filter as maximum. Default value is the current date. + required: false + type: string + - in: query + name: organisationName + description: Organisation name to filter by + required: false + - in: query + name: organisationId + description: Organisation ID to filter by + required: false + - in: query + name: campaign + description: Campaign name to filter by + required: false + - in: query + name: projectId + description: Project IDs to filter by + required: false + - in: query + name: country + description: Country name to filter by + required: false + responses: + 200: + description: Task statistics + 400: + description: Bad Request + 401: + description: Request is not authenticated + 500: + description: Internal Server Error + """ + try: + start_date = validate_date_input(start_date_str) + end_date = validate_date_input(end_date_str) -class TasksStatisticsAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get Task Stats - --- - tags: - - tasks - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: query - name: startDate - description: Date to filter as minimum - required: true - type: string - - in: query - name: endDate - description: Date to filter as maximum. Default value is the current date. - required: false - type: string - - in: query - name: organisationName - description: Organisation name to filter by - required: false - - in: query - name: organisationId - description: Organisation ID to filter by - required: false - - in: query - name: campaign - description: Campaign name to filter by - required: false - - in: query - name: projectId - description: Project IDs to filter by - required: false - - in: query - name: country - description: Country name to filter by - required: false - responses: - 200: - description: Task statistics - 400: - description: Bad Request - 401: - description: Request is not authenticated - 500: - description: Internal Server Error - """ - try: - if request.args.get("startDate"): - start_date = validate_date_input(request.args.get("startDate")) - else: - return { - "Error": "Start date is required", - "SubCode": "MissingDate", - }, 400 - end_date = validate_date_input(request.args.get("endDate", date.today())) - if end_date < start_date: - raise ValueError( - "InvalidDateRange- Start date must be earlier than end date" - ) - if (end_date - start_date) > timedelta(days=366): - raise ValueError( - "InvalidDateRange- Date range can not be bigger than 1 year" - ) - organisation_id = request.args.get("organisationId", None, int) - organisation_name = request.args.get("organisationName", None, str) - campaign = request.args.get("campaign", None, str) - project_id = request.args.get("projectId") - if project_id: - project_id = map(str, project_id.split(",")) - country = request.args.get("country", None, str) - task_stats = StatsService.get_task_stats( - start_date, - end_date, - organisation_id, - organisation_name, - campaign, - project_id, - country, + if end_date < start_date: + raise ValueError( + "InvalidDateRange- Start date must be earlier than end date" ) - return task_stats.to_primitive(), 200 - except (KeyError, ValueError) as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 + if (end_date - start_date) > timedelta(days=366): + raise ValueError( + "InvalidDateRange- Date range cannot be greater than 1 year" + ) + + project_ids = list(map(int, project_id.split(","))) if project_id else None + + task_stats = await StatsService.get_task_stats( + db, + start_date, + end_date, + organisation_id, + organisation_name, + campaign, + project_ids, + country, + ) + return task_stats + + except (KeyError, ValueError) as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) diff --git a/backend/api/teams/actions.py b/backend/api/teams/actions.py index 27a5799afd..f72b58a90d 100644 --- a/backend/api/teams/actions.py +++ b/backend/api/teams/actions.py @@ -1,359 +1,409 @@ -from flask_restful import Resource, request, current_app -from schematics.exceptions import DataError -import threading +from databases import Database +from fastapi import APIRouter, BackgroundTasks, Body, Depends, Request +from fastapi.responses import JSONResponse +from loguru import logger +from backend.db import get_db from backend.models.dtos.message_dto import MessageDTO +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.user import User from backend.services.team_service import ( - TeamService, TeamJoinNotAllowed, + TeamService, TeamServiceError, ) -from backend.services.users.authentication_service import token_auth, tm -from backend.models.postgis.user import User +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/teams", + tags=["teams"], + responses={404: {"description": "Not found"}}, +) TEAM_NOT_FOUND = "Team not found" -class TeamsActionsJoinAPI(Resource): - @token_auth.login_required - def post(self, team_id): - """ - Request to join a team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - responses: - 200: - description: Member added - 403: - description: Forbidden - 404: - description: Not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - try: - TeamService.request_to_join_team(team_id, authenticated_user_id) - return {"Success": "Join request successful"}, 200 - except TeamServiceError as e: - return {"Error": str(e), "SubCode": "InvalidRequest"}, 400 +@router.post("/{team_id}/actions/join/") +async def join_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, +): + """ + Request to join a team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + responses: + 200: + description: Member added + 403: + description: Forbidden + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + async with db.transaction(): + await TeamService.request_to_join_team(team_id, user.id, db) + return JSONResponse( + content={"Success": "Join request successful"}, status_code=200 + ) + except TeamServiceError as e: + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidRequest"}, status_code=400 + ) + - @tm.pm_only(False) - @token_auth.login_required - def patch(self, team_id): - """ - Take action on a team invite - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object to accept or reject a request to join team - schema: - properties: - username: - type: string - required: true - type: - type: string - default: join-response - required: true - role: - type: string - default: member - required: false - action: - type: string - default: accept - required: true - responses: - 200: - description: Member added - 403: - description: Forbidden - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - json_data = request.get_json(force=True) - username = json_data["username"] - request_type = json_data.get("type", "join-response") - action = json_data["action"] - role = json_data.get("role", "member") - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return { +@router.patch("/{team_id}/actions/join/") +async def action_team_invite( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + data: dict = Body(...), +): + """ + Take action on a team invite + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object to accept or reject a request to join team + schema: + properties: + username: + type: string + required: true + type: + type: string + default: join-response + required: true + role: + type: string + default: member + required: false + action: + type: string + default: accept + required: true + responses: + 200: + description: Member added + 403: + description: Forbidden + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + username = data["username"] + request_type = data.get("type", "join-response") + action = data["action"] + role = data.get("role", "member") + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={ "Error": str(e), "SubCode": "InvalidData", - }, 400 + }, + status_code=400, + ) - authenticated_user_id = token_auth.current_user() - if request_type == "join-response": - if TeamService.is_user_team_manager(team_id, authenticated_user_id): - TeamService.accept_reject_join_request( - team_id, authenticated_user_id, username, role, action - ) - return {"Success": "True"}, 200 - else: - return ( - { - "Error": "You don't have permissions to approve this join team request", - "SubCode": "ApproveJoinError", - }, - 403, - ) - elif request_type == "invite-response": - TeamService.accept_reject_invitation_request( - team_id, authenticated_user_id, username, role, action + if request_type == "join-response": + if await TeamService.is_user_team_manager(team_id, user.id, db): + await TeamService.accept_reject_join_request( + team_id, user.id, username, role, action, db + ) + return JSONResponse(content={"Success": "True"}, status_code=200) + else: + return JSONResponse( + content={ + "Error": "You don't have permissions to approve this join team request", + "SubCode": "ApproveJoinError", + }, + status_code=403, ) - return {"Success": "True"}, 200 + elif request_type == "invite-response": + await TeamService.accept_reject_invitation_request( + team_id, user.id, username, role, action, db + ) + return JSONResponse(content={"Success": "True"}, status_code=200) -class TeamsActionsAddAPI(Resource): - @token_auth.login_required - def post(self, team_id): - """ - Add members to the team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object to join team - schema: - properties: - username: - type: string - required: true - role: - type: string - required: false - responses: - 200: - description: Member added - 403: - description: Forbidden - 404: - description: Not found - 500: - description: Internal Server Error - """ - try: - post_data = request.get_json(force=True) - username = post_data["username"] - role = post_data.get("role", None) - except (DataError, KeyError) as e: - current_app.logger.error(f"error validating request: {str(e)}") - return { +@router.post("/{team_id}/actions/add/") +async def add_team_member( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + data: dict = Body(...), +): + """ + Add members to the team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object to join team + schema: + properties: + username: + type: string + required: true + role: + type: string + required: false + responses: + 200: + description: Member added + 403: + description: Forbidden + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + username = data["username"] + role = data.get("role", None) + except (Exception, KeyError) as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={ "Error": str(e), "SubCode": "InvalidData", - }, 400 + }, + status_code=400, + ) + try: + await TeamService.add_user_to_team(team_id, user.id, username, role, db) + return JSONResponse( + content={"Success": "User added to the team"}, status_code=200 + ) + except TeamJoinNotAllowed as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) - try: - authenticated_user_id = token_auth.current_user() - TeamService.add_user_to_team(team_id, authenticated_user_id, username, role) - return {"Success": "User added to the team"}, 200 - except TeamJoinNotAllowed as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.post("/{team_id}/actions/leave/") +async def leave_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + data: dict = Body(...), +): + """ + Removes a user from a team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object to remove user from team + schema: + properties: + username: + type: string + default: 1 + required: true + responses: + 200: + description: Member deleted + 403: + description: Forbidden, if user attempting to ready other messages + 404: + description: Not found + 500: + description: Internal Server Error + """ + username = data["username"] + request_user = await User.get_by_id(user.id, db) + if ( + await TeamService.is_user_team_manager(team_id, user.id, db) + or request_user.username == username + ): + await TeamService.leave_team(team_id, username, db) + return JSONResponse( + content={"Success": "User removed from the team"}, status_code=200 + ) + else: + return JSONResponse( + content={ + "Error": "You don't have permissions to remove {} from this team.".format( + username + ), + "SubCode": "RemoveUserError", + }, + status_code=403, + ) -class TeamsActionsLeaveAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def post(self, team_id): - """ - Removes a user from a team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object to remove user from team - schema: - properties: - username: - type: string - default: 1 - required: true - responses: - 200: - description: Member deleted - 403: - description: Forbidden, if user attempting to ready other messages - 404: - description: Not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - username = request.get_json(force=True)["username"] - request_user = User.get_by_id(authenticated_user_id) - if ( - TeamService.is_user_team_manager(team_id, authenticated_user_id) - or request_user.username == username - ): - TeamService.leave_team(team_id, username) - return {"Success": "User removed from the team"}, 200 - else: - return ( - { - "Error": "You don't have permissions to remove {} from this team.".format( - username - ), - "SubCode": "RemoveUserError", - }, - 403, - ) - - -class TeamsActionsMessageMembersAPI(Resource): - @token_auth.login_required - def post(self, team_id): - """ - Message all team members - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating message - schema: - properties: - subject: - type: string - default: Thanks - required: true - message: - type: string - default: Thanks for your contribution - required: true - responses: - 200: - description: Message sent successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - authenticated_user_id = token_auth.current_user() - message_dto = MessageDTO(request.get_json()) - # Validate if team is present - team = TeamService.get_team_by_id(team_id) - is_manager = TeamService.is_user_team_manager( - team_id, authenticated_user_id +@router.post("/{team_id}/actions/message-members/") +async def message_team( + request: Request, + background_tasks: BackgroundTasks, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, +): + """ + Message all team members + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating message + schema: + properties: + subject: + type: string + default: Thanks + required: true + message: + type: string + default: Thanks for your contribution + required: true + responses: + 200: + description: Message sent successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + request_json = await request.json() + request_json["from_user_id"] = user.id + message_dto = MessageDTO(**request_json) + # Validate if team is present + team = await TeamService.get_team_by_id(team_id, db) + is_manager = await TeamService.is_user_team_manager(team_id, user.id, db) + if not is_manager: + raise ValueError + if not message_dto.message.strip() or not message_dto.subject.strip(): + raise Exception( + {"Error": "Empty message not allowed", "SubCode": "EmptyMessage"} ) - if not is_manager: - raise ValueError - message_dto.from_user_id = authenticated_user_id - message_dto.validate() - if not message_dto.message.strip() or not message_dto.subject.strip(): - raise DataError( - {"Error": "Empty message not allowed", "SubCode": "EmptyMessage"} - ) - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return { - "Error": "Request payload did not match validation", - "SubCode": "InvalidData", - }, 400 - except ValueError: - return { + except ValueError: + return JSONResponse( + content={ "Error": "Unauthorised to send message to team members", "SubCode": "UserNotPermitted", - }, 403 - - try: - threading.Thread( - target=TeamService.send_message_to_all_team_members, - args=(team_id, team.name, message_dto), - ).start() + }, + status_code=403, + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={ + "Error": "Request payload did not match validation", + "SubCode": "InvalidData", + }, + status_code=400, + ) - return {"Success": "Message sent successfully"}, 200 - except ValueError as e: - return {"Error": str(e)}, 403 + try: + background_tasks.add_task( + TeamService.send_message_to_all_team_members, + team_id, + team.name, + message_dto, + user.id, + ) + return JSONResponse( + content={"Success": "Message sent successfully"}, status_code=200 + ) + except ValueError as e: + return JSONResponse(content={"Error": str(e)}, status_code=400) diff --git a/backend/api/teams/resources.py b/backend/api/teams/resources.py index 329eb140d7..738c3644df 100644 --- a/backend/api/teams/resources.py +++ b/backend/api/teams/resources.py @@ -1,458 +1,529 @@ import csv import io -from distutils.util import strtobool from datetime import datetime -from flask_restful import Resource, current_app, request -from flask import Response -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Body, Depends, Query, Request, Response +from fastapi.responses import JSONResponse +from loguru import logger + +from backend.db import get_db from backend.models.dtos.team_dto import NewTeamDTO, TeamSearchDTO, UpdateTeamDTO -from backend.models.postgis.team import Team, TeamMembers -from backend.models.postgis.user import User +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.team import Team from backend.services.organisation_service import OrganisationService from backend.services.team_service import TeamService, TeamServiceError -from backend.services.users.authentication_service import token_auth +from backend.services.users.authentication_service import login_required from backend.services.users.user_service import UserService +router = APIRouter( + prefix="/teams", + tags=["teams"], + responses={404: {"description": "Not found"}}, +) -class TeamsRestAPI(Resource): - @token_auth.login_required - def patch(self, team_id): - """ - Updates a team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: The unique team ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for updating a team - schema: - properties: - name: - type: string - default: HOT - Mappers - logo: - type: string - default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - members: - type: array - items: - schema: - $ref: "#/definitions/TeamMembers" - organisation: - type: string - default: HOT - description: - type: string - default: HOT's mapping editors - inviteOnly: - type: boolean - default: false - responses: - 200: - description: Team updated successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 500: - description: Internal Server Error - """ - try: - team = TeamService.get_team_by_id(team_id) - team_dto = UpdateTeamDTO(request.get_json()) - team_dto.team_id = team_id - team_dto.validate() - authenticated_user_id = token_auth.current_user() - if not TeamService.is_user_team_manager( - team_id, authenticated_user_id - ) and not OrganisationService.can_user_manage_organisation( - team.organisation_id, authenticated_user_id - ): - return { +@router.patch("/{team_id}/") +async def update_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, + team_dto: UpdateTeamDTO = Body(...), +): + """ + Updates a team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: The unique team ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for updating a team + schema: + properties: + name: + type: string + default: HOT - Mappers + logo: + type: string + default: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + members: + type: array + items: + schema: + $ref: "#/definitions/TeamMembers" + organisation: + type: string + default: HOT + description: + type: string + default: HOT's mapping editors + inviteOnly: + type: boolean + default: false + responses: + 200: + description: Team updated successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 500: + description: Internal Server Error + """ + try: + team = await TeamService.get_team_by_id(team_id, db) + team_dto.team_id = team_id + data = await request.json() + if not await TeamService.is_user_team_manager( + team_id, user.id, db + ) and not await OrganisationService.can_user_manage_organisation( + team.organisation_id, user.id, db + ): + return JSONResponse( + content={ "Error": "User is not a admin or a manager for the team", "SubCode": "UserNotTeamManager", - }, 403 - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 + }, + status_code=403, + ) + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) + try: + if ("joinMethod" or "organisations_id") not in data.keys(): + await Team.update_team_members(team, team_dto, db) + else: + await TeamService.update_team(team_dto, db) + return JSONResponse(content={"Status": "Updated"}, status_code=200) + except TeamServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=402) - try: - TeamService.update_team(team_dto) - return {"Status": "Updated"}, 200 - except TeamServiceError as e: - return str(e), 402 - def get(self, team_id): - """ - Retrieves a Team - --- - tags: - - teams - produces: - - application/json - parameters: - - name: team_id - in: path - description: Unique team ID - required: true - type: integer - default: 1 - - in: query - name: omitMemberList - type: boolean - description: Set it to true if you don't want the members list on the response. - default: False - responses: - 200: - description: Team found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Team not found - 500: - description: Internal Server Error - """ - authenticated_user_id = token_auth.current_user() - omit_members = strtobool(request.args.get("omitMemberList", "false")) - if authenticated_user_id is None: - user_id = 0 - else: - user_id = authenticated_user_id - team_dto = TeamService.get_team_as_dto(team_id, user_id, omit_members) - return team_dto.to_primitive(), 200 +@router.get("/{team_id:int}/") +async def retrieve_team( + team_id: int, + omit_member_list: bool = Query( + False, + alias="omitMemberList", + description=("Set to true if you don't want the members list in the response"), + ), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Retrieves a Team + --- + tags: + - teams + produces: + - application/json + parameters: + - name: team_id + in: path + description: Unique team ID + required: true + type: integer + default: 1 + - in: query + name: omitMemberList + type: boolean + description: Set it to true if you don't want the members list on the response. + default: False + responses: + 200: + description: Team found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Team not found + 500: + description: Internal Server Error + """ + user_id = user.id or 0 + team_dto = await TeamService.get_team_as_dto(team_id, user_id, omit_member_list, db) + return team_dto # TODO: Add delete API then do front end services and ui work - @token_auth.login_required - def delete(self, team_id): - """ - Deletes a Team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: team_id - in: path - description: The unique team ID - required: true - type: integer - default: 1 - responses: - 200: - description: Team deleted - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - Team has associated projects - 404: - description: Team not found - 500: - description: Internal Server Error - """ - if not TeamService.is_user_team_manager(team_id, token_auth.current_user()): - return { + +@router.delete("/{team_id}/") +async def delete( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_id: int = None, +): + """ + Deletes a Team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: team_id + in: path + description: The unique team ID + required: true + type: integer + default: 1 + responses: + 200: + description: Team deleted + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden - Team has associated projects + 404: + description: Team not found + 500: + description: Internal Server Error + """ + if not await TeamService.is_user_team_manager(team_id, user.id, db): + return JSONResponse( + content={ "Error": "User is not a manager for the team", "SubCode": "UserNotTeamManager", - }, 401 + }, + status_code=403, + ) - return TeamService.delete_team(team_id) + return await TeamService.delete_team(team_id, db) -class TeamsAllAPI(Resource): - @token_auth.login_required - def get(self): - """ - Gets all teams - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: query - name: team_name - description: name of the team to filter by - type: str - default: null - - in: query - name: member - description: user ID to filter teams that the users belongs to, user must be active. - type: str - default: null - - in: query - name: manager - description: user ID to filter teams that the users has MANAGER role - type: str - default: null - - in: query - name: member_request - description: user ID to filter teams that the user has send invite request to - type: str - default: null - - in: query - name: team_role - description: team role for project - type: str - default: null - - in: query - name: organisation - description: organisation ID to filter teams - type: integer - default: null - - in: query - name: omitMemberList - type: boolean - description: Set it to true if you don't want the members list on the response. - default: False - - in: query - name: fullMemberList - type: boolean - description: Set it to true if you want full members list otherwise it will be limited to 10 per role. - default: True - - in: query - name: paginate - type: boolean - description: Set it to true if you want to paginate the results. - default: False - - in: query - name: page - type: integer - description: Page number to return. - default: 1 - - in: query - name: perPage - type: integer - description: Number of results per page. - default: 10 +@router.get("/") +async def list_teams( + team_name: str | None = Query(None, description="Name of the team to filter by"), + member: int | None = Query( + None, + description="User ID to filter teams that the user belongs to, must be active", + ), + manager: int | None = Query( + None, description="User ID to filter teams where user has MANAGER role" + ), + member_request: int | None = Query( + None, description="User ID to filter teams the user has sent invite request to" + ), + team_role: str | None = Query(None, description="Team role for project"), + organisation: int | None = Query( + None, description="Organisation ID to filter teams" + ), + omit_member_list: bool = Query( + False, + alias="omitMemberList", + description="Set to true to omit members list in the response", + ), + full_member_list: bool = Query( + True, + alias="fullMemberList", + description="Set to true to include full members list, else 10 per role", + ), + paginate: bool = Query( + False, + description="Set to true to paginate results", + ), + page: int = Query(1, description="Page number to return"), + per_page: int = Query( + 10, alias="perPage", description="Number of results per page" + ), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Gets all teams + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: team_name + description: name of the team to filter by + type: str + default: null + - in: query + name: member + description: user ID to filter teams that the users belongs to, user must be active. + type: str + default: null + - in: query + name: manager + description: user ID to filter teams that the users has MANAGER role + type: str + default: null + - in: query + name: member_request + description: user ID to filter teams that the user has send invite request to + type: str + default: null + - in: query + name: team_role + description: team role for project + type: str + default: null + - in: query + name: organisation + description: organisation ID to filter teams + type: integer + default: null + - in: query + name: omitMemberList + type: boolean + description: Set it to true if you don't want the members list on the response. + default: False + - in: query + name: fullMemberList + type: boolean + description: Set it to true if you want full members list otherwise it will be limited to 10 per role. + default: True + - in: query + name: paginate + type: boolean + description: Set it to true if you want to paginate the results. + default: False + - in: query + name: page + type: integer + description: Page number to return. + default: 1 + - in: query + name: perPage + type: integer + description: Number of results per page. + default: 10 - responses: - 201: - description: Team list returned successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - user_id = token_auth.current_user() - search_dto = TeamSearchDTO() - search_dto.team_name = request.args.get("team_name", None) - search_dto.member = request.args.get("member", None) - search_dto.manager = request.args.get("manager", None) - search_dto.member_request = request.args.get("member_request", None) - search_dto.team_role = request.args.get("team_role", None) - search_dto.organisation = request.args.get("organisation", None) - search_dto.omit_member_list = strtobool( - request.args.get("omitMemberList", "false") - ) - search_dto.full_member_list = strtobool( - request.args.get("fullMemberList", "true") - ) - search_dto.paginate = strtobool(request.args.get("paginate", "false")) - search_dto.page = request.args.get("page", 1) - search_dto.per_page = request.args.get("perPage", 10) - search_dto.user_id = user_id - search_dto.validate() + responses: + 201: + description: Team list returned successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + search_dto = TeamSearchDTO( + team_name=team_name, + member=member, + manager=manager, + member_request=member_request, + team_role=team_role, + organisation=organisation, + omit_members=omit_member_list, + full_members_list=full_member_list, + paginate=paginate, + page=page, + per_page=per_page, + user_id=user.id, + ) + teams = await TeamService.get_all_teams(search_dto, db) + return teams - teams = TeamService.get_all_teams(search_dto) - return teams.to_primitive(), 200 - @token_auth.login_required - def post(self): - """ - Creates a new team - --- - tags: - - teams - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating team - schema: - properties: - name: - type: string - default: HOT - Mappers - organisation_id: - type: integer - default: 1 - description: - type: string - visibility: - type: string - enum: - - "PUBLIC" - - "PRIVATE" - joinMethod: - type: string - enum: - - "ANY" - - "BY_REQUEST" - - "BY_INVITE" - responses: - 201: - description: Team created successfully - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 403: - description: Unauthorized - Forbidden - 500: - description: Internal Server Error - """ - user_id = token_auth.current_user() +@router.post("/") +async def create_team( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + team_dto: NewTeamDTO = Body(...), +): + """ + Creates a new team + --- + tags: + - teams + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating team + schema: + properties: + name: + type: string + default: HOT - Mappers + organisation_id: + type: integer + default: 1 + description: + type: string + visibility: + type: string + enum: + - "PUBLIC" + - "PRIVATE" + joinMethod: + type: string + enum: + - "ANY" + - "BY_REQUEST" + - "BY_INVITE" + responses: + 201: + description: Team created successfully + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 403: + description: Unauthorized - Forbidden + 500: + description: Internal Server Error + """ - try: - team_dto = NewTeamDTO(request.get_json()) - team_dto.creator = user_id - team_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 + try: + team_dto.creator = user.id + except Exception as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={"Error": str(e), "SubCode": "InvalidData"}, status_code=400 + ) - try: - organisation_id = team_dto.organisation_id + try: + organisation_id = team_dto.organisation_id - is_org_manager = OrganisationService.is_user_an_org_manager( - organisation_id, user_id + is_org_manager = await OrganisationService.is_user_an_org_manager( + organisation_id, user.id, db + ) + is_admin = await UserService.is_user_an_admin(user.id, db) + if is_admin or is_org_manager: + team_id = await TeamService.create_team(team_dto, db) + return JSONResponse(content={"teamId": team_id}, status_code=201) + else: + error_msg = "User not permitted to create team for the Organisation" + return JSONResponse( + content={"Error": error_msg, "SubCode": "CreateTeamNotPermitted"}, + status_code=403, ) - is_admin = UserService.is_user_an_admin(user_id) - if is_admin or is_org_manager: - team_id = TeamService.create_team(team_dto) - return {"teamId": team_id}, 201 - else: - error_msg = "User not permitted to create team for the Organisation" - return {"Error": error_msg, "SubCode": "CreateTeamNotPermitted"}, 403 - except TeamServiceError as e: - return str(e), 400 + except TeamServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=400) -class TeamsJoinRequestAPI(Resource): - # @tm.pm_only() - @token_auth.login_required - def get(self): - """ - Downloads join requests for a specific team as a CSV. - --- - tags: - - teams - produces: - - text/csv - parameters: - - in: query - name: team_id - description: ID of the team to filter by - required: true - type: integer - default: null - responses: - 200: - description: CSV file with inactive team members - 400: - description: Missing or invalid parameters - 401: - description: Unauthorized access - 500: - description: Internal server error +@router.get("/join_requests/") +async def get_join_requests( + request: Request, + team_id: int = Query(..., description="ID of the team to filter by"), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Downloads join requests for a specific team as a CSV. + --- + tags: + - teams + produces: + - text/csv + parameters: + - in: query + name: team_id + description: ID of the team to filter by + required: true + type: integer + responses: + 200: + description: CSV file with inactive team members + 400: + description: Missing or invalid parameters + 401: + description: Unauthorized access + 500: + description: Internal server error + """ + try: + query = """ + SELECT + u.username AS username, + tm.joined_date AS joined_date, + t.name AS team_name + FROM + team_members tm + INNER JOIN + users u ON tm.user_id = u.id + INNER JOIN + teams t ON tm.team_id = t.id + WHERE + tm.team_id = :team_id + AND tm.active = FALSE """ - # Parse the team_id from query parameters - team_id = request.args.get("team_id", type=int) - if not team_id: - return {"message": "team_id is required"}, 400 + team_members = await db.fetch_all(query=query, values={"team_id": int(team_id)}) - # Query the database - try: - team_members = ( - TeamMembers.query.join(User, TeamMembers.user_id == User.id) - .join(Team, TeamMembers.team_id == Team.id) - .filter(TeamMembers.team_id == team_id, ~TeamMembers.active) - .with_entities( - User.username.label("username"), - TeamMembers.joined_date.label("joined_date"), - Team.name.label("team_name"), - ) - .all() + if not team_members: + return JSONResponse( + content={"message": "No inactive members found for the specified team"}, + status_code=200, ) - if not team_members: - return { - "message": "No inactive members found for the specified team" - }, 200 + csv_output = io.StringIO() + writer = csv.writer(csv_output) + writer.writerow(["Username", "Date Joined (UTC)", "Team Name"]) - # Generate CSV in memory - csv_output = io.StringIO() - writer = csv.writer(csv_output) + for member in team_members: + joined_date = getattr(member, "joined_date") + joined_date_str = ( + joined_date.strftime("%Y-%m-%dT%H:%M:%S") if joined_date else "N/A" + ) writer.writerow( - ["Username", "Application Date (UTC)", "Team Name"] - ) # CSV header + [ + getattr(member, "username"), + joined_date_str, + getattr(member, "team_name"), + ] + ) - for member in team_members: - writer.writerow( - [ - member.username, - member.joined_date.strftime("%Y-%m-%dT%H:%M:%S") - if member.joined_date - else "N/A", - member.team_name, - ] + csv_output.seek(0) + return Response( + content=csv_output.getvalue(), + media_type="text/csv", + headers={ + "Content-Disposition": ( + f"attachment; filename=join_requests_{team_id}_" + f"{datetime.now().strftime('%Y%m%d')}.csv" ) - - # Prepare response - csv_output.seek(0) - return Response( - csv_output.getvalue(), - mimetype="text/csv", - headers={ - "Content-Disposition": ( - "attachment; filename=join_requests_" - f"{team_id}_{datetime.now().strftime('%Y%m%d')}.csv" - ) - }, - ) - except Exception as e: - return {"message": f"Error occurred: {str(e)}"}, 500 + }, + ) + except Exception as e: + return JSONResponse( + content={"message": f"Error occurred: {str(e)}"}, status_code=500 + ) diff --git a/backend/api/users/actions.py b/backend/api/users/actions.py index 0bd77f8c9d..4d13170fab 100644 --- a/backend/api/users/actions.py +++ b/backend/api/users/actions.py @@ -1,371 +1,402 @@ -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Body, Depends, Request +from fastapi.responses import JSONResponse +from loguru import logger -from backend.models.dtos.user_dto import UserDTO, UserRegisterEmailDTO +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO, UserDTO, UserRegisterEmailDTO +from backend.services.interests_service import InterestService from backend.services.messaging.message_service import MessageService -from backend.services.users.authentication_service import token_auth, tm +from backend.services.users.authentication_service import login_required, pm_only from backend.services.users.user_service import UserService, UserServiceError -from backend.services.interests_service import InterestService +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}}, +) -class UsersActionsSetUsersAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def patch(self): - """ - Updates user info - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object to update a user - schema: - properties: - id: - type: integer - example: 1 - name: - type: string - example: Your Name - city: - type: string - example: Your City - country: - type: string - example: Your Country - emailAddress: - type: string - example: test@test.com - twitterId: - type: string - example: twitter handle without @ - facebookId: - type: string - example: facebook username - linkedinId: - type: string - example: linkedin username - gender: - type: string - description: gender - selfDescriptionGender: - type: string - description: gender self-description - responses: - 200: - description: Details saved - 400: - description: Client Error - Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - user_dto = UserDTO(request.get_json()) - if user_dto.email_address == "": - user_dto.email_address = ( - None # Replace empty string with None so validation doesn't break - ) - user_dto.validate() - authenticated_user_id = token_auth.current_user() - if authenticated_user_id != user_dto.id: - return { +@router.patch("/me/actions/set-user/") +async def update_user( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), + data: dict = Body(...), +): + """ + Updates user info + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object to update a user + schema: + properties: + id: + type: integer + example: 1 + name: + type: string + example: Your Name + city: + type: string + example: Your City + country: + type: string + example: Your Country + emailAddress: + type: string + example: test@test.com + twitterId: + type: string + example: twitter handle without @ + facebookId: + type: string + example: facebook username + linkedinId: + type: string + example: linkedin username + gender: + type: string + description: gender + selfDescriptionGender: + type: string + description: gender self-description + responses: + 200: + description: Details saved + 400: + description: Client Error - Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + user_dto = UserDTO(**data) + if user_dto.email_address == "": + user_dto.email_address = ( + None # Replace empty string with None so validation doesn't break + ) + if user.id != user_dto.id: + return JSONResponse( + content={ "Error": "Unable to authenticate", "SubCode": "UnableToAuth", - }, 401 - except ValueError as e: - return {"Error": str(e)}, 400 - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return { + }, + status_code=401, + ) + except ValueError as e: + logger.error(f"error validating request: {str(e)}") + return JSONResponse( + content={ "Error": "Unable to update user details", "SubCode": "InvalidData", - }, 400 - - verification_sent = UserService.update_user_details( - authenticated_user_id, user_dto + }, + status_code=400, ) - return verification_sent, 200 + verification_sent = await UserService.update_user_details(user.id, user_dto, db) + return verification_sent -class UsersActionsSetLevelAPI(Resource): - @tm.pm_only() - @token_auth.login_required - def patch(self, username, level): - """ - Allows PMs to set a user's mapping level - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - - name: level - in: path - description: The mapping level that should be set - required: true - type: string - default: ADVANCED - responses: - 200: - description: Level set - 400: - description: Bad Request - Client Error - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - try: - UserService.set_user_mapping_level(username, level) - return {"Success": "Level set"}, 200 - except UserServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 +@router.patch("/{username}/actions/set-level/{level}/") +async def set_mapping_level( + request: Request, + username, + level, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), +): + """ + Allows PMs to set a user's mapping level + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + - name: level + in: path + description: The mapping level that should be set + required: true + type: string + default: ADVANCED + responses: + 200: + description: Level set + 400: + description: Bad Request - Client Error + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + try: + await UserService.set_user_mapping_level(username, level, db) + return JSONResponse(content={"Success": "Level set"}, status_code=200) + except UserServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) -class UsersActionsSetRoleAPI(Resource): - @tm.pm_only() - @token_auth.login_required - def patch(self, username, role): - """ - Allows PMs to set a user's role - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - - name: role - in: path - description: The role to add - required: true - type: string - default: ADMIN - responses: - 200: - description: Role set - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: User not found - 500: - description: Internal Server Error - """ - try: - UserService.add_role_to_user(token_auth.current_user(), username, role) - return {"Success": "Role Added"}, 200 - except UserServiceError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 403 +@router.patch("/{username}/actions/set-role/{role}/") +async def set_user_role( + request: Request, + username: str, + role: str, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), +): + """ + Allows PMs to set a user's role + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + - name: role + in: path + description: The role to add + required: true + type: string + default: ADMIN + responses: + 200: + description: Role set + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: User not found + 500: + description: Internal Server Error + """ + try: + await UserService.add_role_to_user(user.id, username, role, db) + return JSONResponse(content={"Success": "Role Added"}, status_code=200) + except UserServiceError as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=403, + ) -class UsersActionsSetExpertModeAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def patch(self, is_expert): - """ - Allows user to enable or disable expert mode - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: is_expert - in: path - description: true to enable expert mode, false to disable - required: true - type: string - responses: - 200: - description: Mode set - 400: - description: Bad Request - Client Error - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - try: - UserService.set_user_is_expert( - token_auth.current_user(), is_expert == "true" - ) - return {"Success": "Expert mode updated"}, 200 - except UserServiceError: - return {"Error": "Not allowed"}, 400 +@router.patch("/{user_name}/actions/set-expert-mode/{is_expert}/") +async def set_user_is_expert( + request: Request, + user_name, + is_expert, + user: AuthUserDTO = Depends(pm_only), + db: Database = Depends(get_db), +): + """ + Allows user to enable or disable expert mode + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: is_expert + in: path + description: true to enable expert mode, false to disable + required: true + type: string + responses: + 200: + description: Mode set + 400: + description: Bad Request - Client Error + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + try: + await UserService.set_user_is_expert(user.id, is_expert == "true", db) + return JSONResponse(content={"Success": "Expert mode updated"}, status_code=200) + except UserServiceError: + return JSONResponse(content={"Error": "Not allowed"}, status_code=400) -class UsersActionsVerifyEmailAPI(Resource): - @tm.pm_only(False) - @token_auth.login_required - def patch(self): - """ - Resends the verification email token to the logged in user - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Resends the user their email verification email - 500: - description: Internal Server Error - """ - try: - MessageService.resend_email_validation(token_auth.current_user()) - return {"Success": "Verification email resent"}, 200 - except ValueError as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 +@router.patch("/me/actions/verify-email/") +async def send_verification_email( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Resends the verification email token to the logged in user + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Resends the user their email verification email + 500: + description: Internal Server Error + """ + try: + await MessageService.resend_email_validation(user.id, db) + return JSONResponse( + content={"Success": "Verification email resent"}, status_code=200 + ) + except ValueError as e: + return JSONResponse( + content={"Error": str(e), "SubCode": str(e).split("-")[0]}, status_code=400 + ) -class UsersActionsRegisterEmailAPI(Resource): - def post(self): - """ - Registers users without OpenStreetMap account - --- - tags: - - users - produces: - - application/json - parameters: - - in: body - name: body - required: true - description: JSON object to update a user - schema: - properties: - email: - type: string - example: test@test.com - responses: - 200: - description: User registered - 400: - description: Client Error - Invalid Request - 500: - description: Internal Server Error - """ - try: - user_dto = UserRegisterEmailDTO(request.get_json()) - user_dto.validate() - except DataError as e: - current_app.logger.error(f"error validating request: {str(e)}") - return {"Error": str(e), "SubCode": "InvalidData"}, 400 - try: - user = UserService.register_user_with_email(user_dto) - user_dto = UserRegisterEmailDTO( - dict( - success=True, - email=user_dto.email, - details="User created successfully", - id=user.id, - ) - ) - return user_dto.to_primitive(), 200 - except ValueError as e: - user_dto = UserRegisterEmailDTO(dict(email=user_dto.email, details=str(e))) - return user_dto.to_primitive(), 400 +@router.post("/actions/register/") +async def register_user_with_email( + request: Request, + db: Database = Depends(get_db), + user_dto: UserRegisterEmailDTO = Body(...), +): + """ + Registers users without OpenStreetMap account + --- + tags: + - users + produces: + - application/json + parameters: + - in: body + name: body + required: true + description: JSON object to update a user + schema: + properties: + email: + type: string + example: test@test.com + responses: + 200: + description: User registered + 400: + description: Client Error - Invalid Request + 500: + description: Internal Server Error + """ + try: + user = await UserService.register_user_with_email(user_dto, db) + result = { + "email": user_dto.email, + "success": True, + "details": "User created successfully", + "id": user, + } + user_dto = UserRegisterEmailDTO(**result) + return user_dto.model_dump(by_alias=True) + except ValueError as e: + return JSONResponse(content={"Error": str(e)}, status_code=400) -class UsersActionsSetInterestsAPI(Resource): - @token_auth.login_required - def post(self): - """ - Creates a relationship between user and interests - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating/updating user and interests relationships - schema: - properties: - interests: - type: array - items: - type: integer - responses: - 200: - description: New user interest relationship created - 400: - description: Invalid Request - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - data = request.get_json() - user_interests = InterestService.create_or_update_user_interests( - token_auth.current_user(), data["interests"] - ) - return user_interests.to_primitive(), 200 - except (ValueError, KeyError) as e: - return {"Error": str(e)}, 400 +@router.post("/me/actions/set-interests/") +async def set_user_interests( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), + data: dict = Body(...), +): + """ + Creates a relationship between user and interests + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating/updating user and interests relationships + schema: + properties: + interests: + type: array + items: + type: integer + responses: + 200: + description: New user interest relationship created + 400: + description: Invalid Request + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + user_interests = await InterestService.create_or_update_user_interests( + user.id, data["interests"], db + ) + return user_interests + except (ValueError, KeyError) as e: + return JSONResponse(content={"Error": str(e)}, status_code=400) diff --git a/backend/api/users/openstreetmap.py b/backend/api/users/openstreetmap.py index 7ab26c4bb7..f4080a93ab 100644 --- a/backend/api/users/openstreetmap.py +++ b/backend/api/users/openstreetmap.py @@ -1,46 +1,60 @@ -from flask_restful import Resource +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse -from backend.services.users.authentication_service import token_auth -from backend.services.users.user_service import UserService, OSMServiceError +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.users.authentication_service import login_required +from backend.services.users.user_service import OSMServiceError, UserService +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}}, +) -class UsersOpenStreetMapAPI(Resource): - @token_auth.login_required - def get(self, username): - """ - Get details from OpenStreetMap for a specified username - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded sesesion token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: User found - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - 502: - description: Bad response from OSM - """ - try: - osm_dto = UserService.get_osm_details_for_user(username) - return osm_dto.to_primitive(), 200 - except OSMServiceError as e: - return {"Error": str(e)}, 502 + +@router.get("/{username}/openstreetmap/") +async def get_osm_details( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), + username: str = None, +): + """ + Get details from OpenStreetMap for a specified username + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded sesesion token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: User found + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + 502: + description: Bad response from OSM + """ + try: + osm_dto = await UserService.get_osm_details_for_user(username, db) + return osm_dto.model_dump(by_alias=True) + except OSMServiceError as e: + return JSONResponse(content={"Error": str(e)}, status_code=502) diff --git a/backend/api/users/resources.py b/backend/api/users/resources.py index ab08d78fd9..24c1f8c4f0 100644 --- a/backend/api/users/resources.py +++ b/backend/api/users/resources.py @@ -1,388 +1,445 @@ -from distutils.util import strtobool -from flask_restful import Resource, current_app, request -from schematics.exceptions import DataError +from databases import Database +from fastapi import APIRouter, Depends, Request, Query, Path +from fastapi.responses import JSONResponse +from loguru import logger -from backend.models.dtos.user_dto import UserSearchQuery -from backend.services.users.authentication_service import token_auth -from backend.services.users.user_service import UserService +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO, UserSearchQuery from backend.services.project_service import ProjectService +from backend.services.users.authentication_service import login_required +from backend.services.users.user_service import UserService +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}}, +) -class UsersRestAPI(Resource): - @token_auth.login_required - def get(self, user_id): - """ - Get user information by id - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: user_id - in: path - description: The id of the user - required: true - type: integer - default: 1 - responses: - 200: - description: User found - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - user_dto = UserService.get_user_dto_by_id(user_id, token_auth.current_user()) - return user_dto.to_primitive(), 200 +@router.get("/{user_id}/") +async def get_user( + request: Request, + user_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get user information by id + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: user_id + in: path + description: The id of the user + required: true + type: integer + default: 1 + responses: + 200: + description: User found + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + user_dto = await UserService.get_user_dto_by_id(user_id, user.id, db) + return user_dto -class UsersAllAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get paged list of all usernames - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded sesesion token - required: true - type: string - default: Token sessionTokenHere== - - in: query - name: page - description: Page of results user requested - type: integer - - in: query - name: pagination - description: Whether to return paginated results - type: boolean - default: true - - in: query - name: per_page - description: Number of results per page - type: integer - default: 20 - - in: query - name: username - description: Full or part username - type: string - - in: query - name: role - description: Role of User, eg ADMIN, PROJECT_MANAGER - type: string - - in: query - name: level - description: Level of User, eg BEGINNER - type: string - responses: - 200: - description: Users found - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - try: - query = UserSearchQuery() - query.pagination = strtobool(request.args.get("pagination", "True")) - if query.pagination: - query.page = ( - int(request.args.get("page")) if request.args.get("page") else 1 - ) - query.per_page = request.args.get("perPage", 20) - query.username = request.args.get("username") - query.mapping_level = request.args.get("level") - query.role = request.args.get("role") - query.validate() - except DataError as e: - current_app.logger.error(f"Error validating request: {str(e)}") - return {"Error": "Unable to fetch user list", "SubCode": "InvalidData"}, 400 - users_dto = UserService.get_all_users(query) - return users_dto.to_primitive(), 200 +@router.get("/") +async def list_users( + page: int = Query(1, description="Page of results user requested"), + pagination: bool = Query(True, description="Whether to return paginated results"), + per_page: int = Query( + 20, alias="perPage", description="Number of results per page" + ), + username: str | None = Query(None, description="Full or part username"), + role: str | None = Query( + None, description="Role of User, e.g. ADMIN, PROJECT_MANAGER" + ), + level: str | None = Query(None, description="Level of User, e.g. BEGINNER"), + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get paged list of all usernames + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded sesesion token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: page + description: Page of results user requested + type: integer + - in: query + name: pagination + description: Whether to return paginated results + type: boolean + default: true + - in: query + name: per_page + description: Number of results per page + type: integer + default: 20 + - in: query + name: username + description: Full or part username + type: string + - in: query + name: role + description: Role of User, eg ADMIN, PROJECT_MANAGER + type: string + - in: query + name: level + description: Level of User, eg BEGINNER + type: string + responses: + 200: + description: Users found + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + try: + query = UserSearchQuery( + pagination=pagination, + page=page if pagination else None, + per_page=per_page, + username=username, + mapping_level=level, + role=role, + ) + except Exception as e: + logger.error(f"Error validating request: {str(e)}") + return JSONResponse( + content={"Error": "Unable to fetch user list", "SubCode": "InvalidData"}, + status_code=400, + ) + users_dto = await UserService.get_all_users(query, db) + return users_dto -class UsersQueriesUsernameAPI(Resource): - @token_auth.login_required - def get(self, username): - """ - Get user information by OpenStreetMap username - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: User found - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - user_dto = UserService.get_user_dto_by_username( - username, token_auth.current_user() - ) - return user_dto.to_primitive(), 200 +@router.get("/queries/favorites/") +async def get_user_favorite_projects( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get projects favorited by a user + --- + tags: + - favorites + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Projects favorited by user + 404: + description: User not found + 500: + description: Internal Server Error + """ + favs_dto = await UserService.get_projects_favorited(user.id, db) + return favs_dto -class UsersQueriesUsernameFilterAPI(Resource): - @token_auth.login_required - def get(self, username): - """ - Get paged lists of users matching OpenStreetMap username filter - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's partial or full OpenStreetMap username - type: string - default: ab - - in: query - name: page - description: Page of results user requested - type: integer - - in: query - name: projectId - description: Optional, promote project participants to head of results - type: integer - responses: - 200: - description: Users found - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - page = int(request.args.get("page")) if request.args.get("page") else 1 - project_id = request.args.get("projectId", None, int) - users_dto = UserService.filter_users(username, project_id, page) - return users_dto.to_primitive(), 200 +@router.get("/queries/{username}/") +async def get_osm_user_info( + request: Request, + username: str, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get user information by OpenStreetMap username + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: User found + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + user_dto = await UserService.get_user_dto_by_username(username, user.id, db) + return user_dto -class UsersQueriesOwnLockedAPI(Resource): - @token_auth.login_required - def get(self): - """ - Gets any locked task on the project for the logged in user - --- - tags: - - mapping - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Task user is working on - 401: - description: Unauthorized - Invalid credentials - 404: - description: User is not working on any tasks - 500: - description: Internal Server Error - """ - locked_tasks = ProjectService.get_task_for_logged_in_user( - token_auth.current_user() - ) - return locked_tasks.to_primitive(), 200 +@router.get("/queries/filter/{username}/") +async def get_paginated_osm_user_info( + username: str = Path( + ..., description="Mapper's partial or full OpenStreetMap username" + ), + page: int = Query(1, description="Page of results user requested"), + project_id: int | None = Query( + None, + alias="projectId", + description="Optional, promote project participants to head of results", + ), + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get paged lists of users matching OpenStreetMap username filter + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's partial or full OpenStreetMap username + type: string + default: ab + - in: query + name: page + description: Page of results user requested + type: integer + - in: query + name: projectId + description: Optional, promote project participants to head of results + type: integer + responses: + 200: + description: Users found + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + users_dto = await UserService.filter_users(username, project_id, page, db) + return users_dto -class UsersQueriesOwnLockedDetailsAPI(Resource): - @token_auth.login_required - def get(self): - """ - Gets details of any locked task for the logged in user - --- - tags: - - mapping - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - responses: - 200: - description: Task user is working on - 401: - description: Unauthorized - Invalid credentials - 404: - description: User is not working on any tasks - 500: - description: Internal Server Error - """ - preferred_locale = request.environ.get("HTTP_ACCEPT_LANGUAGE") - locked_tasks = ProjectService.get_task_details_for_logged_in_user( - token_auth.current_user(), preferred_locale - ) - return locked_tasks.to_primitive(), 200 +@router.get("/queries/tasks/locked/") +async def get_task_locked_by_user( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Gets any locked task on the project for the logged in user + --- + tags: + - mapping + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + responses: + 200: + description: Task user is working on + 401: + description: Unauthorized - Invalid credentials + 404: + description: User is not working on any tasks + 500: + description: Internal Server Error + """ + locked_tasks = await ProjectService.get_task_for_logged_in_user(user.id, db) + return locked_tasks.model_dump(by_alias=True) -class UsersQueriesFavoritesAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get projects favorited by a user - --- - tags: - - favorites - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - responses: - 200: - description: Projects favorited by user - 404: - description: User not found - 500: - description: Internal Server Error - """ - favs_dto = UserService.get_projects_favorited(token_auth.current_user()) - return favs_dto.to_primitive(), 200 +@router.get("/queries/tasks/locked/details/") +async def get_task_details_locked_by_user( + request: Request, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Gets details of any locked task for the logged in user + --- + tags: + - mapping + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + responses: + 200: + description: Task user is working on + 401: + description: Unauthorized - Invalid credentials + 404: + description: User is not working on any tasks + 500: + description: Internal Server Error + """ + preferred_locale = request.headers.get("accept-language") + locked_tasks = await ProjectService.get_task_details_for_logged_in_user( + user.id, preferred_locale, db + ) + return locked_tasks.model_dump(by_alias=True) -class UsersQueriesInterestsAPI(Resource): - @token_auth.login_required - def get(self, username): - """ - Get interests by username - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - responses: - 200: - description: User interests returned - 404: - description: User not found - 500: - description: Internal Server Error +@router.get("/{username}/queries/interests/") +async def get_user_interests( + request: Request, + username: str, + db: Database = Depends(get_db), + request_user: AuthUserDTO = Depends(login_required), +): + """ + Get interests by username + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + responses: + 200: + description: User interests returned + 404: + description: User not found + 500: + description: Internal Server Error + """ + query = """ + SELECT u.id, u.username, array_agg(i.name) AS interests + FROM users u + LEFT JOIN user_interests ui ON u.id = ui.user_id + LEFT JOIN interests i ON ui.interest_id = i.id + WHERE u.username = :username + GROUP BY u.id, u.username """ - user = UserService.get_user_by_username(username) - interests_dto = UserService.get_interests(user) - return interests_dto.to_primitive(), 200 + user = await db.fetch_one(query, {"username": username}) + interests_dto = await UserService.get_interests(user, db) + return interests_dto -class UsersRecommendedProjectsAPI(Resource): - @token_auth.login_required - def get(self, username): - """ - Get recommended projects for a user - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Accept-Language - description: Language user is requesting - type: string - required: true - default: en - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: Recommended projects found - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: No recommended projects found - 500: - description: Internal Server Error - """ - locale = ( - request.environ.get("HTTP_ACCEPT_LANGUAGE") - if request.environ.get("HTTP_ACCEPT_LANGUAGE") - else "en" - ) - user_dto = UserService.get_recommended_projects(username, locale) - return user_dto.to_primitive(), 200 +@router.get("/{username}/recommended-projects/") +async def get_recommended_projects( + request: Request, + username, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get recommended projects for a user + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Accept-Language + description: Language user is requesting + type: string + required: true + default: en + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: Recommended projects found + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: No recommended projects found + 500: + description: Internal Server Error + """ + locale = ( + request.headers.get("accept-language") + if request.headers.get("accept-language") + else "en" + ) + user_dto = await UserService.get_recommended_projects(username, locale, db) + return user_dto.model_dump(by_alias=True) diff --git a/backend/api/users/statistics.py b/backend/api/users/statistics.py index e3e6f9dd8c..77c4df6e39 100644 --- a/backend/api/users/statistics.py +++ b/backend/api/users/statistics.py @@ -1,182 +1,214 @@ -from json import JSONEncoder from datetime import date, timedelta -from flask_restful import Resource, request -import requests -from backend.services.users.user_service import UserService -from backend.services.stats_service import StatsService -from backend.services.interests_service import InterestService -from backend.services.users.authentication_service import token_auth +import requests +from databases import Database +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import JSONResponse +from typing import Optional from backend.api.utils import validate_date_input -from backend.config import EnvironmentConfig - - -class UsersStatisticsAPI(Resource, JSONEncoder): - @token_auth.login_required - def get(self, username): - """ - Get detailed stats about a user by OpenStreetMap username - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: username - in: path - description: Mapper's OpenStreetMap username - required: true - type: string - default: Thinkwhere - responses: - 200: - description: User found - 401: - description: Unauthorized - Invalid credentials - 404: - description: User not found - 500: - description: Internal Server Error - """ - stats_dto = UserService.get_detailed_stats(username) - return stats_dto.to_primitive(), 200 +from backend.config import settings +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.interests_service import InterestService +from backend.services.stats_service import StatsService +from backend.services.users.authentication_service import login_required +from backend.services.users.user_service import UserService +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}}, +) -class UsersStatisticsInterestsAPI(Resource): - @token_auth.login_required - def get(self, user_id): - """ - Get rate of contributions from a user given their interests - --- - tags: - - interests - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: user_id - in: path - description: User ID - required: true - type: integer - responses: - 200: - description: Interest found - 401: - description: Unauthorized - Invalid credentials - 500: - description: Internal Server Error - """ - rate = InterestService.compute_contributions_rate(user_id) - return rate.to_primitive(), 200 +@router.get("/{username}/statistics/") +async def get_user_stats( + request: Request, + username: str, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get detailed stats about a user by OpenStreetMap username + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: username + in: path + description: Mapper's OpenStreetMap username + required: true + type: string + default: Thinkwhere + responses: + 200: + description: User found + 401: + description: Unauthorized - Invalid credentials + 404: + description: User not found + 500: + description: Internal Server Error + """ + stats_dto = await UserService.get_detailed_stats(username, db) + return stats_dto -class UsersStatisticsAllAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get stats about users registered within a period of time - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: query - name: startDate - description: Initial date - required: true - type: string - - in: query - name: endDate - description: Final date. - type: string - responses: - 200: - description: User statistics - 400: - description: Bad Request - 401: - description: Request is not authenticated - 500: - description: Internal Server Error - """ - try: - if request.args.get("startDate"): - start_date = validate_date_input(request.args.get("startDate")) - else: - return { - "Error": "Start date is required", - "SubCode": "MissingDate", - }, 400 - end_date = validate_date_input(request.args.get("endDate", date.today())) - if end_date < start_date: - raise ValueError( - "InvalidDateRange- Start date must be earlier than end date" - ) - if (end_date - start_date) > timedelta(days=366 * 3): - raise ValueError( - "InvalidDateRange- Date range can not be bigger than 1 year" - ) - stats = StatsService.get_all_users_statistics(start_date, end_date) - return stats.to_primitive(), 200 - except (KeyError, ValueError) as e: - return {"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, 400 +@router.get("/{user_id}/statistics/interests/") +async def get_user_interests_statistics( + user_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get rate of contributions from a user given their interests + --- + tags: + - interests + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: user_id + in: path + description: User ID + required: true + type: integer + responses: + 200: + description: Interest found + 401: + description: Unauthorized - Invalid credentials + 500: + description: Internal Server Error + """ + rate = await InterestService.compute_contributions_rate(user_id, db) + return rate -class OhsomeProxyAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get HomePage Stats - --- - tags: - - system - produces: - - application/json - parameters: +@router.get("/statistics/") +async def get_period_user_stats( + start_date_str: str = Query( + ..., alias="startDate", description="Initial date (e.g., YYYY-MM-DD)" + ), + end_date_str: Optional[str] = Query( + None, alias="endDate", description="Final date (e.g., YYYY-MM-DD)" + ), + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get stats about users registered within a period of time + --- + tags: + - users + produces: + - application/json + parameters: - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: query + name: startDate + description: Initial date + required: true + type: string - in: query - name: url - type: string - description: get user stats for osm contributions - responses: - 200: - description: User stats - 500: - description: Internal Server Error - """ - url = request.args.get("url") - if not url: - return {"Error": "URL is None", "SubCode": "URL not provided"}, 400 - try: - headers = {"Authorization": f"Basic {EnvironmentConfig.OHSOME_STATS_TOKEN}"} + name: endDate + description: Final date. + type: string + responses: + 200: + description: User statistics + 400: + description: Bad Request + 401: + description: Request is not authenticated + 500: + description: Internal Server Error + """ + try: + start_date = validate_date_input(start_date_str) + if end_date_str: + end_date = validate_date_input(end_date_str) + else: + end_date: str = date.today() + + if end_date < start_date: + raise ValueError( + "InvalidDateRange- Start date must be earlier than end date" + ) + if (end_date - start_date) > timedelta(days=366 * 3): + raise ValueError( + "InvalidDateRange- Date range can not be bigger than 1 year" + ) + + stats = await StatsService.get_all_users_statistics(start_date, end_date, db) + return stats.model_dump(by_alias=True) + + except (KeyError, ValueError) as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + - # Make the GET request with headers - response = requests.get(url, headers=headers) - return response.json(), 200 - except Exception as e: - return {"Error": str(e), "SubCode": "Error fetching data"}, 400 +@router.get("/statistics/ohsome/") +async def get_ohsome_stats( + url: str = Query(None, description="Get user stats for OSM contributions"), + user: AuthUserDTO = Depends(login_required), +): + """ + Get HomePage Stats + --- + tags: + - system + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: query + name: url + type: string + description: get user stats for osm contributions + responses: + 200: + description: User stats + 500: + description: Internal Server Error + """ + if not url: + return JSONResponse( + content={"Error": "URL is None", "SubCode": "URL not provided"}, + status_code=400, + ) + try: + headers = {"Authorization": f"Basic {settings.OHSOME_STATS_TOKEN}"} + # Make the GET request with headers + response = requests.get(url, headers=headers) + return response.json() + except Exception as e: + return JSONResponse( + content={"Error": str(e), "SubCode": "Error fetching data"}, status_code=400 + ) diff --git a/backend/api/users/tasks.py b/backend/api/users/tasks.py index e0375024eb..7f96eff2dd 100644 --- a/backend/api/users/tasks.py +++ b/backend/api/users/tasks.py @@ -1,113 +1,139 @@ -from flask_restful import Resource, request +from databases import Database from dateutil.parser import parse as date_parse +from fastapi import APIRouter, Depends, Request, Query, Path +from fastapi.responses import JSONResponse -from backend.services.users.authentication_service import token_auth +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.services.users.authentication_service import login_required from backend.services.users.user_service import UserService +from typing import Optional -class UsersTasksAPI(Resource): - @token_auth.login_required - def get(self, user_id): - """ - Get a list of tasks a user has interacted with - --- - tags: - - users - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: user_id - in: path - description: Mapper's OpenStreetMap ID - required: true - type: integer - - in: query - name: status - description: Task Status filter - required: false - type: string - default: null - - in: query - name: project_status - description: Project Status filter - required: false - type: string - default: null - - in: query - name: project_id - description: Project id - required: false - type: integer - default: null - - in: query - name: start_date - description: Date to filter as minimum - required: false - type: string - default: null - - in: query - name: end_date - description: Date to filter as maximum - required: false - type: string - default: null - - in: query - name: sort_by - description: - criteria to sort by. The supported options are action_date, -action_date, project_id, -project_id. - The default value is -action_date. - required: false - type: string - - in: query - name: page - description: Page of results user requested - type: integer - - in: query - name: page_size - description: Size of page, defaults to 10 - type: integer - responses: - 200: - description: Mapped projects found - 404: - description: No mapped projects found - 500: - description: Internal Server Error - """ - try: - user = UserService.get_user_by_id(user_id) - status = request.args.get("status") - project_status = request.args.get("project_status") - project_id = int(request.args.get("project_id", 0)) - start_date = ( - date_parse(request.args.get("start_date")) - if request.args.get("start_date") - else None - ) - end_date = ( - date_parse(request.args.get("end_date")) - if request.args.get("end_date") - else None - ) - sort_by = request.args.get("sort_by", "-action_date") - tasks = UserService.get_tasks_dto( - user.id, - project_id=project_id, - project_status=project_status, - task_status=status, - start_date=start_date, - end_date=end_date, - page=request.args.get("page", None, type=int), - page_size=request.args.get("page_size", 10, type=int), - sort_by=sort_by, - ) - return tasks.to_primitive(), 200 - except ValueError: - return {"tasks": [], "pagination": {"total": 0}}, 200 +router = APIRouter( + prefix="/users", + tags=["users"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/{user_id}/tasks/") +async def get_user_tasks( + request: Request, + user_id: int = Path(..., description="Mapper's OpenStreetMap ID"), + status: Optional[str] = Query(None, description="Task Status filter"), + project_status: Optional[str] = Query(None, description="Project Status filter"), + project_id: Optional[int] = Query(None, description="Project ID"), + start_date: Optional[str] = Query( + None, description="Date to filter as minimum (ISO format)" + ), + end_date: Optional[str] = Query( + None, description="Date to filter as maximum (ISO format)" + ), + sort_by: Optional[str] = Query( + "-action_date", + description="Sort criteria. Options: action_date, -action_date, project_id, -project_id", + ), + page: int = Query(1, description="Page number"), + page_size: int = Query(10, description="Page size"), + request_user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Get a list of tasks a user has interacted with + --- + tags: + - users + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: user_id + in: path + description: Mapper's OpenStreetMap ID + required: true + type: integer + - in: query + name: status + description: Task Status filter + required: false + type: string + default: null + - in: query + name: project_status + description: Project Status filter + required: false + type: string + default: null + - in: query + name: project_id + description: Project id + required: false + type: integer + default: null + - in: query + name: start_date + description: Date to filter as minimum + required: false + type: string + default: null + - in: query + name: end_date + description: Date to filter as maximum + required: false + type: string + default: null + - in: query + name: sort_by + description: + criteria to sort by. The supported options are action_date, -action_date, project_id, -project_id. + The default value is -action_date. + required: false + type: string + - in: query + name: page + description: Page of results user requested + type: integer + - in: query + name: page_size + description: Size of page, defaults to 10 + type: integer + responses: + 200: + description: Mapped projects found + 404: + description: No mapped projects found + 500: + description: Internal Server Error + """ + try: + user = await UserService.get_user_by_id(user_id, db) + + parsed_start_date = date_parse(start_date) if start_date else None + parsed_end_date = date_parse(end_date) if end_date else None + + tasks = await UserService.get_tasks_dto( + user.id, + project_id=project_id, + project_status=project_status, + task_status=status, + start_date=parsed_start_date, + end_date=parsed_end_date, + page=page, + page_size=page_size, + sort_by=sort_by, + db=db, + ) + return tasks + + except ValueError: + print("InvalidDateRange - Date range cannot be bigger than 1 year") + return JSONResponse( + content={"tasks": [], "pagination": {"total": 0}}, status_code=200 + ) diff --git a/backend/api/utils.py b/backend/api/utils.py index 6a2330a498..86a0439e92 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -1,5 +1,5 @@ -from functools import wraps from datetime import date, datetime +from functools import wraps class TMAPIDecorators: diff --git a/backend/config.py b/backend/config.py index fe3b7ae40a..2876f37898 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,128 +1,159 @@ +import json import logging import os +from functools import lru_cache +from typing import Optional + from dotenv import load_dotenv +from pydantic import PostgresDsn, ValidationInfo, field_validator +from pydantic_settings import BaseSettings -class EnvironmentConfig: +class Settings(BaseSettings): """Base class for configuration.""" """ Most settings can be defined through environment variables. """ + class Config: + ignored_types = (type(json),) + # Load configuration from file load_dotenv( os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "tasking-manager.env") ) ) + APP_NAME: str = "Tasking Manager" + DEBUG: bool = False + PROFILING: bool = os.getenv("PROFILING", False) + EXTRA_CORS_ORIGINS: list = ( + os.getenv("EXTRA_CORS_ORIGINS", "").split(",") + if os.getenv("EXTRA_CORS_ORIGINS") + else ["*"] + ) # The base url the application is reachable - APP_BASE_URL = os.getenv("TM_APP_BASE_URL", "http://127.0.0.1:5000/").rstrip("/") - - API_VERSION = os.getenv("TM_APP_API_VERSION", "v2") - ORG_CODE = os.getenv("TM_ORG_CODE", "HOT") - ORG_NAME = os.getenv("TM_ORG_NAME", "Humanitarian OpenStreetMap Team") - ORG_LOGO = os.getenv( + APP_BASE_URL: str = os.getenv("TM_APP_BASE_URL", "http://127.0.0.1:5000/").rstrip( + "/" + ) + TM_APP_API_URL: str = os.getenv("TM_APP_API_URL", "http://127.0.0.1:3000/api") + API_VERSION: str = os.getenv("TM_APP_API_VERSION", "v2") + ORG_CODE: str = os.getenv("TM_ORG_CODE", "HOT") + ORG_NAME: str = os.getenv("TM_ORG_NAME", "Humanitarian OpenStreetMap Team") + ORG_LOGO: str = os.getenv( "TM_ORG_LOGO", "https://cdn.hotosm.org/tasking-manager/uploads/1588741335578_hot-logo.png", ) - ENVIRONMENT = os.getenv("TM_ENVIRONMENT", "") + ENVIRONMENT: str = os.getenv("TM_ENVIRONMENT", "") # The default tag used in the OSM changeset comment - DEFAULT_CHANGESET_COMMENT = os.getenv( + DEFAULT_CHANGESET_COMMENT: str = os.getenv( "TM_DEFAULT_CHANGESET_COMMENT", "#hot-tm-stage-project" ) # The address to use as the sender on auto generated emails - EMAIL_FROM_ADDRESS = os.getenv("TM_EMAIL_FROM_ADDRESS", "noreply@hotosmmail.org") + EMAIL_FROM_ADDRESS: str = os.getenv( + "TM_EMAIL_FROM_ADDRESS", "noreply@hotosmmail.org" + ) # The address to use as the receiver in contact form. - EMAIL_CONTACT_ADDRESS = os.getenv("TM_EMAIL_CONTACT_ADDRESS", "sysadmin@hotosm.org") + EMAIL_CONTACT_ADDRESS: str = os.getenv( + "TM_EMAIL_CONTACT_ADDRESS", "sysadmin@hotosm.org" + ) # A freely definable secret key for connecting the front end with the back end - SECRET_KEY = os.getenv("TM_SECRET", None) + SECRET_KEY: str = os.getenv("TM_SECRET", None) # OSM API, Nomimatim URLs - OSM_SERVER_URL = os.getenv("OSM_SERVER_URL", "https://www.openstreetmap.org") - OSM_NOMINATIM_SERVER_URL = os.getenv( + OSM_SERVER_URL: str = os.getenv("OSM_SERVER_URL", "https://www.openstreetmap.org") + OSM_NOMINATIM_SERVER_URL: str = os.getenv( "OSM_NOMINATIM_SERVER_URL", "https://nominatim.openstreetmap.org" ) - # Database connection - POSTGRES_USER = os.getenv("POSTGRES_USER", "postgres") - POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", None) - POSTGRES_ENDPOINT = os.getenv("POSTGRES_ENDPOINT", "localhost") - POSTGRES_DB = os.getenv("POSTGRES_DB", "postgres") - POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") - - # Assamble the database uri - if os.getenv("TM_DB", False): - SQLALCHEMY_DATABASE_URI = os.getenv("TM_DB", None) - elif os.getenv("DB_CONNECT_PARAM_JSON", False): - """ - This section reads JSON formatted Database connection parameters passed - from AWS Secrets Manager with the ENVVAR key `DB_CONNECT_PARAM_JSON` - and forms a valid SQLALCHEMY DATABASE URI - """ - import json - - _params = json.loads(os.getenv("DB_CONNECT_PARAM_JSON", None)) - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{_params.get('username')}" - + f":{_params.get('password')}" - + f"@{_params.get('host')}" - + f":{_params.get('port')}" - + f"/{_params.get('dbname')}" - ) - else: - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{POSTGRES_USER}" - + f":{POSTGRES_PASSWORD}" - + f"@{POSTGRES_ENDPOINT}:" - + f"{POSTGRES_PORT}" - + f"/{POSTGRES_DB}" + POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres") + POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", None) + POSTGRES_ENDPOINT: str = os.getenv("POSTGRES_ENDPOINT", "localhost") + POSTGRES_DB: str = os.getenv("POSTGRES_DB", "postgres") + POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432") + + SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None + + @field_validator("SQLALCHEMY_DATABASE_URI", mode="before") + @classmethod + def assemble_db_connection( + cls, v: Optional[str], info: ValidationInfo + ) -> Optional[str]: + """Build Postgres connection from environment variables or JSON config.""" + if v: + return v + + if os.getenv("TM_DB"): + return os.getenv("TM_DB") + + if os.getenv("DB_CONNECT_PARAM_JSON"): + params = json.loads(os.getenv("DB_CONNECT_PARAM_JSON")) + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=params.get("username"), + password=params.get("password"), + host=params.get("host"), + port=int(params.get("port")), + path=f"{params.get('dbname')}", + ) + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=info.data.get("POSTGRES_USER"), + password=info.data.get("POSTGRES_PASSWORD"), + host=info.data.get("POSTGRES_ENDPOINT"), + port=int(info.data.get("POSTGRES_PORT")), + path=f"{info.data.get('POSTGRES_DB')}", ) # Logging settings - LOG_LEVEL = os.getenv("TM_LOG_LEVEL", logging.DEBUG) - LOG_DIR = os.getenv("TM_LOG_DIR", "/home/appuser/logs") + LOG_LEVEL: int = os.getenv("TM_LOG_LEVEL", logging.DEBUG) + LOG_DIR: str = os.getenv("TM_LOG_DIR", "/home/appuser/logs") + USE_SENTRY: bool = os.getenv("USE_SENTRY", "false").lower() == "true" # Mapper Level values represent number of OSM changesets - MAPPER_LEVEL_INTERMEDIATE = int(os.getenv("TM_MAPPER_LEVEL_INTERMEDIATE", 250)) - MAPPER_LEVEL_ADVANCED = int(os.getenv("TM_MAPPER_LEVEL_ADVANCED", 500)) + MAPPER_LEVEL_INTERMEDIATE: int = int(os.getenv("TM_MAPPER_LEVEL_INTERMEDIATE", 250)) + MAPPER_LEVEL_ADVANCED: int = int(os.getenv("TM_MAPPER_LEVEL_ADVANCED", 500)) # Time to wait until task auto-unlock (e.g. '2h' or '7d' or '30m' or '1h30m') - TASK_AUTOUNLOCK_AFTER = os.getenv("TM_TASK_AUTOUNLOCK_AFTER", "2h") + TASK_AUTOUNLOCK_AFTER: str = os.getenv("TM_TASK_AUTOUNLOCK_AFTER", "2h") # Configuration for sending emails - MAIL_SERVER = os.getenv("TM_SMTP_HOST", None) - MAIL_PORT = os.getenv("TM_SMTP_PORT", "587") - MAIL_USE_TLS = bool(int(os.getenv("TM_SMTP_USE_TLS", True))) - MAIL_USE_SSL = bool(int(os.getenv("TM_SMTP_USE_SSL", False))) - MAIL_USERNAME = os.getenv("TM_SMTP_USER", None) - MAIL_PASSWORD = os.getenv("TM_SMTP_PASSWORD", None) - MAIL_DEFAULT_SENDER = os.getenv("TM_EMAIL_FROM_ADDRESS", "noreply@hotosmmail.org") - MAIL_DEBUG = True if LOG_LEVEL == "DEBUG" else False + MAIL_SERVER: Optional[str] = os.getenv("TM_SMTP_HOST", "smtp.gmail.com") + MAIL_PORT: str = os.getenv("TM_SMTP_PORT", "587") + MAIL_USE_TLS: bool = bool(int(os.getenv("TM_SMTP_USE_TLS", True))) + MAIL_USE_SSL: bool = bool(int(os.getenv("TM_SMTP_USE_SSL", False))) + MAIL_USERNAME: Optional[str] = os.getenv("TM_SMTP_USER", None) + MAIL_PASSWORD: Optional[str] = os.getenv("TM_SMTP_PASSWORD", None) + MAIL_DEFAULT_SENDER: str = os.getenv( + "TM_EMAIL_FROM_ADDRESS", "noreply@hotosmmail.org" + ) + MAIL_DEBUG: bool = True if LOG_LEVEL == "DEBUG" else False if os.getenv("SMTP_CREDENTIALS", False): """ This section reads JSON formatted SMTP connection parameters passed from AWS Secrets Manager with the ENVVAR key `SMTP_CREDENTIALS`. """ - import json - _params = json.loads(os.getenv("SMTP_CREDENTIALS", None)) - MAIL_SERVER = _params.get("SMTP_HOST", None) - MAIL_PORT = _params.get("SMTP_PORT", "587") - MAIL_USE_TLS = bool(int(_params.get("SMTP_USE_TLS", True))) - MAIL_USE_SSL = bool(int(_params.get("SMTP_USE_SSL", False))) - MAIL_USERNAME = _params.get("SMTP_USER", None) - MAIL_PASSWORD = _params.get("SMTP_PASSWORD", None) + _params: dict = json.loads(os.getenv("SMTP_CREDENTIALS", None)) + MAIL_SERVER: str = _params.get("SMTP_HOST", None) + MAIL_PORT: str = _params.get("SMTP_PORT", "587") + MAIL_USE_TLS: bool = bool(int(_params.get("SMTP_USE_TLS", True))) + MAIL_USE_SSL: bool = bool(int(_params.get("SMTP_USE_SSL", False))) + MAIL_USERNAME: str = _params.get("SMTP_USER", None) + MAIL_PASSWORD: str = _params.get("SMTP_PASSWORD", None) # If disabled project update emails will not be sent. - SEND_PROJECT_EMAIL_UPDATES = bool(os.getenv("TM_SEND_PROJECT_EMAIL_UPDATES", True)) + SEND_PROJECT_EMAIL_UPDATES: bool = bool( + os.getenv("TM_SEND_PROJECT_EMAIL_UPDATES", True) + ) # Languages offered by the Tasking Manager # Please note that there must be exactly the same number of Codes as languages. - SUPPORTED_LANGUAGES = { + SUPPORTED_LANGUAGES: dict = { "codes": os.getenv( "TM_SUPPORTED_LANGUAGES_CODES", ", ".join( @@ -192,65 +223,79 @@ class EnvironmentConfig: } # Connection to OSM authentification system - OAUTH_API_URL = "{}/api/0.6/".format(OSM_SERVER_URL) - OAUTH_CLIENT_ID = os.getenv("TM_CLIENT_ID", None) - OAUTH_CLIENT_SECRET = os.getenv("TM_CLIENT_SECRET", None) - OAUTH_SCOPE = os.getenv("TM_SCOPE", "read_prefs write_api") - OAUTH_REDIRECT_URI = os.getenv("TM_REDIRECT_URI", None) + OAUTH_API_URL: str = "{}/api/0.6/".format(OSM_SERVER_URL) + OAUTH_CLIENT_ID: str = os.getenv("TM_CLIENT_ID", None) + OAUTH_CLIENT_SECRET: str = os.getenv("TM_CLIENT_SECRET", None) + OAUTH_SCOPE: str = os.getenv("TM_SCOPE", "read_prefs write_api") + OAUTH_REDIRECT_URI: str = os.getenv("TM_REDIRECT_URI", None) + DB_NAME: str = os.getenv("TM_DB", "tasking-manager") if os.getenv("OAUTH2_APP_CREDENTIALS", False): """ This section reads JSON formatted OAuth2 app credentials passed from AWS Secrets Manager with the ENVVAR key `OAUTH2_APP_CREDENTIALS`. """ - import json - _params = json.loads(os.getenv("OAUTH2_APP_CREDENTIALS", None)) - OAUTH_CLIENT_ID = _params.get("CLIENT_ID", None) - OAUTH_CLIENT_SECRET = _params.get("CLIENT_SECRET", None) - OAUTH_REDIRECT_URI = _params.get("REDIRECT_URI", None) - OAUTH_SCOPE = _params.get("ACCESS_SCOPE", "read_prefs write_api") + _params: dict = json.loads(os.getenv("OAUTH2_APP_CREDENTIALS", None)) + OAUTH_CLIENT_ID: str = _params.get("CLIENT_ID", None) + OAUTH_CLIENT_SECRET: str = _params.get("CLIENT_SECRET", None) + OAUTH_REDIRECT_URI: str = _params.get("REDIRECT_URI", None) + OAUTH_SCOPE: str = _params.get("ACCESS_SCOPE", "read_prefs write_api") # Some more definitions (not overridable) - SQLALCHEMY_ENGINE_OPTIONS = { + SQLALCHEMY_ENGINE_OPTIONS: dict = { "pool_size": 10, "max_overflow": 10, } - SEND_FILE_MAX_AGE_DEFAULT = 0 - SQLALCHEMY_TRACK_MODIFICATIONS = False + SEND_FILE_MAX_AGE_DEFAULT: int = 0 + SQLALCHEMY_TRACK_MODIFICATIONS: bool = False # Image upload Api - IMAGE_UPLOAD_API_KEY = os.getenv("TM_IMAGE_UPLOAD_API_KEY", None) - IMAGE_UPLOAD_API_URL = os.getenv("TM_IMAGE_UPLOAD_API_URL", None) + IMAGE_UPLOAD_API_KEY: Optional[str] = os.getenv("TM_IMAGE_UPLOAD_API_KEY", None) + IMAGE_UPLOAD_API_URL: Optional[str] = os.getenv("TM_IMAGE_UPLOAD_API_URL", None) if os.getenv("IMAGE_UPLOAD_CREDENTIALS", False): """ This section reads JSON formatted Image Upload credentials passed from AWS Secrets Manager with the ENVVAR key `IMAGE_UPLOAD_CREDENTIALS`. """ - import json - _params = json.loads(os.getenv("IMAGE_UPLOAD_CREDENTIALS"), None) - IMAGE_UPLOAD_API_KEY = _params.get("IMAGE_UPLOAD_API_KEY", None) - IMAGE_UPLOAD_API_URL = _params.get("IMAGE_UPLOAD_API_URL", None) + _params: dict = json.loads(os.getenv("IMAGE_UPLOAD_CREDENTIALS"), None) + IMAGE_UPLOAD_API_KEY: str = _params.get("IMAGE_UPLOAD_API_KEY", None) + IMAGE_UPLOAD_API_URL: str = _params.get("IMAGE_UPLOAD_API_URL", None) # Sentry backend DSN - SENTRY_BACKEND_DSN = os.getenv("TM_SENTRY_BACKEND_DSN", None) + SENTRY_BACKEND_DSN: Optional[str] = os.getenv("TM_SENTRY_BACKEND_DSN", None) # Ohsome Stats Token - OHSOME_STATS_TOKEN = os.getenv("OHSOME_STATS_TOKEN", None) + OHSOME_STATS_TOKEN: str = os.getenv("OHSOME_STATS_TOKEN", None) -class TestEnvironmentConfig(EnvironmentConfig): - POSTGRES_TEST_DB = os.getenv("POSTGRES_TEST_DB", None) +class TestEnvironmentConfig(Settings): + POSTGRES_TEST_DB: str = os.getenv("POSTGRES_TEST_DB", None) - ENVIRONMENT = "test" + ENVIRONMENT: str = "test" - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://{EnvironmentConfig.POSTGRES_USER}" - + f":{EnvironmentConfig.POSTGRES_PASSWORD}" - + f"@{EnvironmentConfig.POSTGRES_ENDPOINT}:" - + f"{EnvironmentConfig.POSTGRES_PORT}" - + f"/{POSTGRES_TEST_DB}" + SQLALCHEMY_DATABASE_URI: PostgresDsn = PostgresDsn.build( + scheme="postgresql+asyncpg", + username=Settings().POSTGRES_USER, + password=Settings().POSTGRES_PASSWORD, + host=Settings().POSTGRES_ENDPOINT, + port=int(Settings().POSTGRES_PORT), + path=f"{POSTGRES_TEST_DB}", ) - LOG_LEVEL = "DEBUG" + + LOG_LEVEL: str = "DEBUG" + + +@lru_cache +def get_settings(env_type: str = "default"): + """Cache settings when accessed throughout app.""" + _settings = Settings() + if env_type == "test": + _settings = TestEnvironmentConfig() + return _settings + + +settings = get_settings(env_type="default") +test_settings = get_settings(env_type="test") diff --git a/backend/cron_jobs.py b/backend/cron_jobs.py new file mode 100644 index 0000000000..de173959da --- /dev/null +++ b/backend/cron_jobs.py @@ -0,0 +1,172 @@ +import asyncio +import datetime + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from loguru import logger + +from backend.db import db_connection +from backend.models.postgis.task import Task + + +async def auto_unlock_tasks(): + logger.info("Started auto-unlock_tasks") + try: + async with db_connection.database.connection() as conn: + two_hours_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=120) + projects_query = """ + SELECT DISTINCT project_id + FROM task_history + WHERE action_date > :two_hours_ago + """ + projects = await conn.fetch_all( + query=projects_query, values={"two_hours_ago": two_hours_ago} + ) + for project in projects: + project_id = project["project_id"] + logger.info(f"Processing project_id: {project_id}") + await Task.auto_unlock_tasks(project_id, conn) + except Exception as e: + logger.error(f"Error in auto_unlock_tasks: {e}") + finally: + logger.info("Finished auto-unlock_tasks") + + +async def update_all_project_stats(): + async with db_connection.database.connection() as conn: + logger.info("Started updating project stats.") + # await conn.execute("UPDATE users SET projects_mapped = NULL;") + projects_query = "SELECT DISTINCT id FROM projects;" + projects = await conn.fetch_all(query=projects_query) + for project in projects: + project_id = project["id"] + logger.info(f"Processing project ID: {project_id}") + await conn.execute( + """ + UPDATE projects + SET total_tasks = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id), + tasks_mapped = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 2), + tasks_validated = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 4), + tasks_bad_imagery = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 6) + WHERE id = :project_id; + """, + {"project_id": project_id}, + ) + await conn.execute( + """ + UPDATE users + SET projects_mapped = + CASE + WHEN :project_id = ANY(projects_mapped) THEN projects_mapped + ELSE array_append(projects_mapped, :project_id) + END + WHERE id IN ( + SELECT DISTINCT user_id + FROM task_history + WHERE action = 'STATE_CHANGE' AND project_id = :project_id + ); + """, + {"project_id": project_id}, + ) + logger.info("Finished updating project stats.") + + +async def update_recent_updated_project_stats(): + async with db_connection.database.connection() as conn: + logger.info("Started updating recently updated projects' project stats.") + one_week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7) + projects_query = """ + SELECT DISTINCT id + FROM projects + WHERE last_updated > :one_week_ago; + """ + projects = await conn.fetch_all( + query=projects_query, values={"one_week_ago": one_week_ago} + ) + for project in projects: + project_id = project["id"] + logger.info(f"Processing project ID: {project_id}") + await conn.execute( + """ + UPDATE projects + SET total_tasks = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id), + tasks_mapped = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 2), + tasks_validated = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 4), + tasks_bad_imagery = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 6) + WHERE id = :project_id; + """, + {"project_id": project_id}, + ) + await conn.execute( + """ + UPDATE users + SET projects_mapped = + CASE + WHEN :project_id = ANY(projects_mapped) THEN projects_mapped + ELSE array_append(projects_mapped, :project_id) + END + WHERE id IN ( + SELECT DISTINCT user_id + FROM task_history + WHERE action = 'STATE_CHANGE' AND project_id = :project_id + ); + """, + {"project_id": project_id}, + ) + logger.info("Finished updating project stats.") + + +async def setup_cron_jobs(): + scheduler = AsyncIOScheduler() + scheduler.add_job( + auto_unlock_tasks, + IntervalTrigger(minutes=120), + misfire_grace_time=3600, + id="auto_unlock_tasks", + replace_existing=True, + ) + scheduler.add_job( + update_all_project_stats, + CronTrigger(hour=0, minute=0), + misfire_grace_time=3600, + id="update_project_stats", + replace_existing=True, + ) + scheduler.add_job( + update_recent_updated_project_stats, + IntervalTrigger(minutes=60), + id="update_recent_updated_project_stats", + misfire_grace_time=3600, + replace_existing=True, + ) + scheduler.start() + logger.info("Scheduler initialized and jobs scheduled.") + logger.info(f"Scheduled jobs: {scheduler.get_jobs()}") + + +async def main(): + try: + # Initialize the connection pool + logger.info("Connecting to the database...") + await db_connection.database.connect() + logger.info("Database connection established.") + + await setup_cron_jobs() + + # Keeping the process alive. + while True: + await asyncio.sleep(3600) + except (KeyboardInterrupt, SystemExit): + logger.info("Shutting down...") + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + finally: + # Close the connection pool + logger.info("Disconnecting from the database...") + await db_connection.database.disconnect() + logger.info("Database connection closed.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000000..37a0d9e2b8 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,32 @@ +from databases import Database +from sqlalchemy.orm import declarative_base + +from backend.config import settings + +Base = declarative_base() + + +class DatabaseConnection: + """Manages database connection (encode databases)""" + + def __init__(self): + self.database = Database( + settings.SQLALCHEMY_DATABASE_URI.unicode_string(), min_size=4, max_size=8 + ) + + async def connect(self): + """Connect to the database.""" + await self.database.connect() + + async def disconnect(self): + """Disconnect from the database.""" + await self.database.disconnect() + + +db_connection = DatabaseConnection() + + +async def get_db(): + """Get the database connection from the pool.""" + async with db_connection.database.connection() as connection: + yield connection diff --git a/backend/exceptions.py b/backend/exceptions.py index b06b6c251d..619b386d7a 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -1,4 +1,4 @@ -from werkzeug.exceptions import HTTPException +from fastapi import HTTPException from backend import ERROR_MESSAGES @@ -35,25 +35,19 @@ def get_message_from_sub_code(sub_code: str) -> str: class BaseException(HTTPException): - """Base exception class for all http exceptions in the application""" - - def __init__(self, sub_code, message, status_code, **kwargs): + def __init__(self, sub_code: str, message: str, status_code: int, **kwargs): self.sub_code = sub_code self.message = message - self.status_code = status_code self.kwargs = kwargs - response = self.to_dict() - HTTPException.__init__(self, message, response) - - def to_dict(self): - return { + detail = { "error": { - "code": self.status_code, - "sub_code": self.sub_code, - "message": self.message, - "details": self.kwargs, + "code": status_code, + "sub_code": sub_code, + "message": message, + "details": kwargs, } - }, self.status_code + } + super().__init__(status_code=status_code, detail=detail) class BadRequest(BaseException): diff --git a/backend/gunicorn.py b/backend/gunicorn.py deleted file mode 100644 index def4d6e5b1..0000000000 --- a/backend/gunicorn.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -bind = "0.0.0.0:5000" -worker_class = "gevent" -workers = (os.cpu_count() or 1) * 2 + 1 -threads = (os.cpu_count() or 1) * 2 + 1 -preload = True -timeout = 180 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000..70e7f837d2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,183 @@ +import logging +import sys +from contextlib import asynccontextmanager + +import sentry_sdk +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from loguru import logger as log +from pyinstrument import Profiler +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from starlette.middleware.authentication import AuthenticationMiddleware + +from backend.config import settings +from backend.db import db_connection +from backend.exceptions import BadRequest, Conflict, Forbidden, NotFound, Unauthorized +from backend.routes import add_api_end_points +from backend.services.users.authentication_service import TokenAuthBackend + + +def get_application() -> FastAPI: + """Get the FastAPI app instance, with settings.""" + + @asynccontextmanager + async def lifespan(app): + await db_connection.connect() + yield + await db_connection.disconnect() + + _app = FastAPI( + lifespan=lifespan, + title=settings.APP_NAME, + description="HOTOSM Tasking Manager", + version="0.1.0", + license_info={ + "name": "BSD 2-Clause", + "url": "https://raw.githubusercontent.com/hotosm/tasking-manager/develop/LICENSE.md", + }, + debug=settings.DEBUG, + openapi_url="/api/openapi.json", + docs_url="/api/docs", + ) + + # Initialize Sentry only if USE_SENTRY is enabled + if settings.USE_SENTRY: + sentry_sdk.init( + dsn=settings.SENTRY_BACKEND_DSN, + environment=settings.ENVIRONMENT, + traces_sample_rate=0.1, + ignore_errors=[BadRequest, NotFound, Unauthorized, Forbidden, Conflict], + ) + + _app.add_middleware(SentryAsgiMiddleware) + + # Custom exception handler for invalid token and logout. + @_app.exception_handler(HTTPException) + async def custom_http_exception_handler(request: Request, exc: HTTPException): + if exc.status_code == 401 and "InvalidToken" in exc.detail.get("SubCode", ""): + return JSONResponse( + content={ + "Error": exc.detail["Error"], + "SubCode": exc.detail["SubCode"], + }, + status_code=exc.status_code, + headers={"WWW-Authenticate": "Bearer"}, + ) + + if isinstance(exc.detail, dict) and "error" in exc.detail: + error_response = exc.detail + else: + error_response = { + "error": { + "code": exc.status_code, + "sub_code": "UNKNOWN_ERROR", + "message": str(exc.detail), + "details": {}, + } + } + + return JSONResponse( + status_code=exc.status_code, + content=error_response, + ) + + PROFILING = settings.PROFILING + if PROFILING: + + @_app.middleware("http") + async def pyinstrument_middleware(request, call_next): + profiling = request.query_params.get("profile", False) + if profiling: + profiler = Profiler(async_mode=True) + profiler.start() + await call_next(request) + profiler.stop() + return HTMLResponse(profiler.output_html()) + else: + return await call_next(request) + + _app.add_middleware( + CORSMiddleware, + allow_origins=settings.EXTRA_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Disposition"], + ) + + _app.add_middleware( + AuthenticationMiddleware, backend=TokenAuthBackend(), on_error=None + ) + add_api_end_points(_app) + return _app + + +class InterceptHandler(logging.Handler): + """Intercept python standard lib logging.""" + + def emit(self, record): + """Retrieve context where the logging call occurred. + + This happens to be in the 6th frame upward. + """ + logger_opt = log.opt(depth=6, exception=record.exc_info) + logger_opt.log(record.levelno, record.getMessage()) + + +def get_logger(): + """Override FastAPI logger with custom loguru.""" + # Hook all other loggers into ours + logger_name_list = [name for name in logging.root.manager.loggerDict] + for logger_name in logger_name_list: + logging.getLogger(logger_name).setLevel(10) + logging.getLogger(logger_name).handlers = [] + if logger_name == "sqlalchemy": + # Don't hook sqlalchemy, very verbose + continue + if "." not in logger_name: + logging.getLogger(logger_name).addHandler(InterceptHandler()) + + log.remove() + log.add( + sys.stderr, + level=settings.LOG_LEVEL, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} " + "| {name}:{function}:{line} | {message}" + ), + enqueue=True, # Run async / non-blocking + colorize=True, + backtrace=True, # More detailed tracebacks + catch=True, # Prevent app crashes + ) + + # Only log to file in production + if not settings.DEBUG: + log.add( + "/opt/logs/tm.json", + level=settings.LOG_LEVEL, + enqueue=True, + serialize=True, # JSON format + rotation="00:00", # New file at midnight + retention="10 days", + ) + + log.add( + "/opt/logs/create_project.json", + level=settings.LOG_LEVEL, + enqueue=True, + serialize=True, + rotation="00:00", + retention="10 days", + filter=lambda record: record["extra"].get("task") == "create_project", + ) + + +api = get_application() + + +@api.get("/") +async def home(): + """Redirect home to docs.""" + return RedirectResponse("/api/docs") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index e69de29bb2..55a8c5c353 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -0,0 +1,26 @@ +from backend.models.postgis.application import Application # noqa: F401 +from backend.models.postgis.banner import Banner # noqa: F401 +from backend.models.postgis.campaign import Campaign # noqa: F401 +from backend.models.postgis.custom_editors import CustomEditor # noqa: F401 +from backend.models.postgis.interests import Interest # noqa: F401 +from backend.models.postgis.licenses import License # noqa: F401 +from backend.models.postgis.mapping_issues import MappingIssueCategory # noqa: F401 +from backend.models.postgis.message import Message # noqa: F401 +from backend.models.postgis.notification import Notification # noqa: F401 +from backend.models.postgis.organisation import Organisation # noqa: F401 +from backend.models.postgis.partner import Partner # noqa: F401 +from backend.models.postgis.priority_area import PriorityArea # noqa: F401 +from backend.models.postgis.project_chat import ProjectChat # noqa: F401 +from backend.models.postgis.project_info import ProjectInfo # noqa: F401 +from backend.models.postgis.project import Project # noqa: F401 +from backend.models.postgis.project_partner import ProjectPartnership # noqa: F401 +from backend.models.postgis.release_version import ReleaseVersion # noqa: F401 +from backend.models.postgis.task_annotation import TaskAnnotation # noqa: F401 +from backend.models.postgis.task import ( # noqa: F401 + TaskMappingIssue, + TaskHistory, + Task, + TaskInvalidationHistory, +) +from backend.models.postgis.team import Team # noqa: F401 +from backend.models.postgis.user import User # noqa: F401 diff --git a/backend/models/dtos/__init__.py b/backend/models/dtos/__init__.py index 457fd2410a..71d85aecef 100644 --- a/backend/models/dtos/__init__.py +++ b/backend/models/dtos/__init__.py @@ -1,9 +1,10 @@ -from functools import wraps -from flask import request -from schematics.exceptions import DataError +# from functools import wraps +# from flask import request +# from schematics.exceptions import DataError -from backend.exceptions import BadRequest + +# from backend.exceptions import BadRequest def get_validation_errors(e): @@ -13,48 +14,48 @@ def get_validation_errors(e): ] -def validate_request(dto_class): - """ - Decorator to validate request against a DTO class. - -------------------------------- - NOTE: This decorator should be applied after the token_auth decorator (if used) so that - the authenticated user id can be set on the DTO if the DTO has a user_id field. - -------------------------------- - Parameters: - dto_class: DTO - The DTO class to validate against - """ - - def decorator(f): - @wraps(f) - def wrapper(*args, **kwargs): - from backend.services.users.authentication_service import token_auth - - try: - dto = dto_class() - - # Set attribute values from request body, query parameters, and path parameters - for attr in dto.__class__._fields: - if request.is_json and attr in request.json: - setattr(dto, attr, request.json[attr]) - elif attr in request.args: - setattr(dto, attr, request.args.get(attr)) - elif attr in kwargs: - setattr(dto, attr, kwargs[attr]) - - # Set authenticated user id if user_id is a field in the DTO - if "user_id" in dto.__class__._fields: - dto.user_id = token_auth.current_user() - - dto.validate() - request.validated_dto = ( - dto # Set validated DTO on request object for use in view function - ) - except DataError as e: - field_errors = get_validation_errors(e) - raise BadRequest(field_errors=field_errors) - return f(*args, **kwargs) - - return wrapper - - return decorator +# def validate_request(dto_class): +# """ +# Decorator to validate request against a DTO class. +# -------------------------------- +# NOTE: This decorator should be applied after the token_auth decorator (if used) so that +# the authenticated user id can be set on the DTO if the DTO has a user_id field. +# -------------------------------- +# Parameters: +# dto_class: DTO +# The DTO class to validate against +# """ + +# def decorator(f): +# @wraps(f) +# def wrapper(*args, **kwargs): +# from backend.services.users.authentication_service import token_auth + +# try: +# dto = dto_class() + +# # Set attribute values from request body, query parameters, and path parameters +# for attr in dto.__class__._fields: +# if request.is_json and attr in request.json: +# setattr(dto, attr, request.json[attr]) +# elif attr in request.args: +# setattr(dto, attr, request.args.get(attr)) +# elif attr in kwargs: +# setattr(dto, attr, kwargs[attr]) + +# # Set authenticated user id if user_id is a field in the DTO +# if "user_id" in dto.__class__._fields: +# dto.user_id = token_auth.current_user() + +# dto.validate() +# request.validated_dto = ( +# dto # Set validated DTO on request object for use in view function +# ) +# except DataError as e: +# field_errors = get_validation_errors(e) +# raise BadRequest(field_errors=field_errors) +# return f(*args, **kwargs) + +# return wrapper + +# return decorator diff --git a/backend/models/dtos/application_dto.py b/backend/models/dtos/application_dto.py index 0fe23a2900..e5522c9cc8 100644 --- a/backend/models/dtos/application_dto.py +++ b/backend/models/dtos/application_dto.py @@ -1,21 +1,19 @@ -from schematics.types import IntType, ListType, ModelType, StringType, UTCDateTimeType -from schematics import Model +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel, Field -class ApplicationDTO(Model): + +class ApplicationDTO(BaseModel): """Describes JSON model used for creating grids""" - id = IntType(required=True, serialized_name="keyId") - user = IntType(required=True, serialized_name="userId") - app_key = StringType(required=True, serialized_name="applicationkey") - created = UTCDateTimeType(required=True, serialized_name="createdDate") + id: Optional[int] = Field(None, alias="keyId") + user: Optional[int] = Field(None, alias="userId") + app_key: Optional[str] = Field(None, alias="applicationkey") + created: Optional[datetime] = Field(None, alias="createdDate") -class ApplicationsDTO(Model): +class ApplicationsDTO(BaseModel): """Describes an array of Application DTOs""" - def __init__(self): - super().__init__() - self.applications = [] - - applications = ListType(ModelType(ApplicationDTO)) + applications: List[ApplicationDTO] = Field([], alias="applications") diff --git a/backend/models/dtos/banner_dto.py b/backend/models/dtos/banner_dto.py index aed6a27b3c..e3aed5b93a 100644 --- a/backend/models/dtos/banner_dto.py +++ b/backend/models/dtos/banner_dto.py @@ -1,12 +1,10 @@ -from schematics import Model -from schematics.types import ( - BooleanType, - StringType, -) +from typing import Optional +from pydantic import BaseModel, Field -class BannerDTO(Model): + +class BannerDTO(BaseModel): """Describes a JSON model for a banner""" - message = StringType(required=True, max_length=255) - visible = BooleanType() + message: str = Field(max_length=255) + visible: Optional[bool] = True diff --git a/backend/models/dtos/campaign_dto.py b/backend/models/dtos/campaign_dto.py index b4837b0b2d..4b89bd88c7 100644 --- a/backend/models/dtos/campaign_dto.py +++ b/backend/models/dtos/campaign_dto.py @@ -1,51 +1,65 @@ -from schematics import Model -from schematics.types import StringType, IntType, ListType, ModelType +from typing import List, Optional + +from pydantic import BaseModel, Field +from pydantic.functional_validators import field_validator + from backend.models.dtos.organisation_dto import OrganisationDTO -from schematics.exceptions import ValidationError def is_existent(value): if value.strip() == "": - raise ValidationError("Empty campaign name string") + raise ValueError("Empty campaign name string") return value -class NewCampaignDTO(Model): +class NewCampaignDTO(BaseModel): """Describes JSON model to create a campaign""" - name = StringType(serialize_when_none=False, validators=[is_existent]) - logo = StringType(serialize_when_none=False) - url = StringType(serialize_when_none=False) - description = StringType(serialize_when_none=False) - organisations = ListType(IntType, serialize_when_none=False) + name: str = Field(serialize_when_none=False) + logo: Optional[str] = Field(None, serialize_when_none=False) + url: Optional[str] = Field(None, serialize_when_none=False) + description: Optional[str] = Field(None, serialize_when_none=False) + organisations: Optional[List[int]] = Field(None, serialize_when_none=False) + + @field_validator("name", mode="before") + def validate_type(cls, value): + if value is None: + return value + return is_existent(value) -class CampaignDTO(Model): - """Describes JSON model for an existing campaign""" +class CampaignDTO(BaseModel): + id: Optional[int] = None + name: Optional[str] = None + logo: Optional[str] = None + url: Optional[str] = None + description: Optional[str] = None + organisations: List[OrganisationDTO] = Field(default=None, alias="organisations") - id = IntType(serialize_when_none=False) - name = StringType(serialize_when_none=False) - logo = StringType(serialize_when_none=False) - url = StringType(serialize_when_none=False) - description = StringType(serialize_when_none=False) - organisations = ListType(ModelType(OrganisationDTO), serialize_when_none=False) + class Config: + populate_by_name = True -class CampaignProjectDTO(Model): +class CampaignProjectDTO(BaseModel): """DTO used to define available campaign connected projects""" - project_id = IntType() - campaign_id = IntType() + project_id: int + campaign_id: int -class CampaignOrganisationDTO(Model): +class CampaignOrganisationDTO(BaseModel): """DTO used to define available campaign connected projects""" - organisation_id = IntType() - campaign_id = IntType() + organisation_id: int + campaign_id: int + + +class ListCampaignDTO(BaseModel): + id: Optional[int] = None + name: Optional[str] = None -class CampaignListDTO(Model): +class CampaignListDTO(BaseModel): """DTO used to define available campaigns""" def __init__(self): @@ -53,4 +67,4 @@ def __init__(self): super().__init__() self.campaigns = [] - campaigns = ListType(ModelType(CampaignDTO)) + campaigns: Optional[List[ListCampaignDTO]] = None diff --git a/backend/models/dtos/grid_dto.py b/backend/models/dtos/grid_dto.py index 12dcb73db9..ddc3fac713 100644 --- a/backend/models/dtos/grid_dto.py +++ b/backend/models/dtos/grid_dto.py @@ -1,19 +1,24 @@ -from schematics.types import BaseType, BooleanType, IntType, StringType -from schematics import Model +from pydantic import BaseModel, Field -class GridDTO(Model): +class GridDTO(BaseModel): """Describes JSON model used for creating grids""" - area_of_interest = BaseType(required=True, serialized_name="areaOfInterest") - grid = BaseType(required=True) - clip_to_aoi = BooleanType(required=True, serialized_name="clipToAoi") + area_of_interest: dict = Field(..., alias="areaOfInterest") + grid: dict = Field(..., alias="grid") + clip_to_aoi: bool = Field(..., alias="clipToAoi") + class Config: + populate_by_name = True -class SplitTaskDTO(Model): + +class SplitTaskDTO(BaseModel): """DTO used to split a task""" - user_id = IntType(required=True) - task_id = IntType(required=True) - project_id = IntType(required=True) - preferred_locale = StringType(default="en") + user_id: int = Field(alias="userId") + task_id: int = Field(alias="taskId") + project_id: int = Field(alias="projectId") + preferred_locale: str = "en" + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/interests_dto.py b/backend/models/dtos/interests_dto.py index fd60f73b07..a9329536f5 100644 --- a/backend/models/dtos/interests_dto.py +++ b/backend/models/dtos/interests_dto.py @@ -1,42 +1,47 @@ -from schematics import Model -from schematics.types import IntType, StringType, FloatType, BooleanType -from schematics.types.compound import ListType, ModelType +from typing import List, Optional +from pydantic import BaseModel, Field -class InterestDTO(Model): - """DTO for a interest.""" - id = IntType() - name = StringType(required=True, min_length=1) - user_selected = BooleanType( - serialized_name="userSelected", serialize_when_none=False +class InterestDTO(BaseModel): + id: Optional[int] = None + name: Optional[str] = Field(default=None, min_length=1) + user_selected: Optional[bool] = Field( + alias="userSelected", default=None, none_if_default=True + ) + count_projects: Optional[int] = Field( + serialize=False, alias="countProjects", default=None + ) + count_users: Optional[int] = Field( + serialize=False, alias="countUsers", default=None ) - count_projects = IntType(serialize_when_none=False, serialized_name="countProjects") - count_users = IntType(serialize_when_none=False, serialized_name="countUsers") + class Config: + populate_by_name = True -class InterestsListDTO(Model): + +class InterestsListDTO(BaseModel): """DTO for a list of interests.""" def __init__(self): super().__init__() self.interests = [] - interests = ListType(ModelType(InterestDTO)) + interests: Optional[List[InterestDTO]] = None -class InterestRateDTO(Model): +class InterestRateDTO(BaseModel): """DTO for a interest rate.""" - name = StringType() - rate = FloatType() + name: str + rate: float -class InterestRateListDTO(Model): +class InterestRateListDTO(BaseModel): """DTO for a list of interests rates.""" def __init__(self): super().__init__() - self.interests = [] + self.rates = [] - rates = ListType(ModelType(InterestRateDTO)) + rates: Optional[List[InterestRateDTO]] = None diff --git a/backend/models/dtos/licenses_dto.py b/backend/models/dtos/licenses_dto.py index addd20aecb..15d04a2929 100644 --- a/backend/models/dtos/licenses_dto.py +++ b/backend/models/dtos/licenses_dto.py @@ -1,22 +1,22 @@ -from schematics import Model -from schematics.types import StringType, IntType -from schematics.types.compound import ListType, ModelType +from typing import List, Optional +from pydantic import BaseModel, Field -class LicenseDTO(Model): + +class LicenseDTO(BaseModel): """DTO used to define a mapping license""" - license_id = IntType(serialized_name="licenseId") - name = StringType(required=True) - description = StringType(required=True) - plain_text = StringType(required=True, serialized_name="plainText") + license_id: Optional[int] = Field(None, alias="licenseId") + name: Optional[str] = None + description: Optional[str] = None + plain_text: Optional[str] = Field(None, alias="plainText") -class LicenseListDTO(Model): +class LicenseListDTO(BaseModel): """DTO for all mapping licenses""" def __init__(self): super().__init__() self.licenses = [] - licenses = ListType(ModelType(LicenseDTO)) + licenses: Optional[List[LicenseDTO]] = None diff --git a/backend/models/dtos/mapping_dto.py b/backend/models/dtos/mapping_dto.py index d394c0117f..ceca066abc 100644 --- a/backend/models/dtos/mapping_dto.py +++ b/backend/models/dtos/mapping_dto.py @@ -1,10 +1,11 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import StringType, IntType, UTCDateTimeType -from schematics.types.compound import ListType, ModelType -from backend.models.postgis.statuses import TaskStatus +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, ValidationError, validator + from backend.models.dtos.mapping_issues_dto import TaskMappingIssueDTO from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO +from backend.models.postgis.statuses import TaskStatus def is_valid_mapped_status(value): @@ -20,98 +21,122 @@ def is_valid_mapped_status(value): raise ValidationError(f"Invalid task Status. Valid values are {valid_values}") -class LockTaskDTO(Model): +class LockTaskDTO(BaseModel): """DTO used to lock a task for mapping""" - user_id = IntType(required=True) - task_id = IntType(required=True) - project_id = IntType(required=True) - preferred_locale = StringType(default="en") + user_id: int + task_id: int + project_id: int + preferred_locale: str = "en" + @validator("preferred_locale", pre=True, always=True) + def set_default_preferred_locale(cls, v): + return v or "en" -class MappedTaskDTO(Model): - """Describes the model used to update the status of one task after mapping""" - user_id = IntType(required=True) - status = StringType(required=True, validators=[is_valid_mapped_status]) - comment = StringType() - task_id = IntType(required=True) - project_id = IntType(required=True) - preferred_locale = StringType(default="en") +class MappedTaskDTO(BaseModel): + """Describes the model used to update the status of one task after mapping""" + user_id: int + status: str = Field(required=True, validators=[is_valid_mapped_status]) + comment: Optional[str] = None + task_id: int + project_id: int + preferred_locale: str = "en" -class StopMappingTaskDTO(Model): - """Describes the model used to stop mapping and reset the status of one task""" - user_id = IntType(required=True) - comment = StringType() - task_id = IntType(required=True) - project_id = IntType(required=True) - preferred_locale = StringType(default="en") +class StopMappingTaskDTO(BaseModel): + user_id: int + comment: Optional[str] = None + task_id: int + project_id: int + preferred_locale: str = Field(default="en") -class TaskHistoryDTO(Model): +class TaskHistoryDTO(BaseModel): """Describes an individual action that was performed on a mapping task""" - history_id = IntType(serialized_name="historyId") - task_id = StringType(serialized_name="taskId") - action = StringType() - action_text = StringType(serialized_name="actionText") - action_date = UTCDateTimeType(serialized_name="actionDate") - action_by = StringType(serialized_name="actionBy") - picture_url = StringType(serialized_name="pictureUrl") - issues = ListType(ModelType(TaskMappingIssueDTO)) + history_id: Optional[int] = Field(alias="historyId", default=None) + task_id: Optional[int] = Field(alias="taskId", default=None) + action: Optional[str] = None + action_text: Optional[str] = Field(alias="actionText", default=None) + action_date: datetime = Field(alias="actionDate", default=None) + action_by: Optional[str] = Field(alias="actionBy", default=None) + picture_url: Optional[str] = Field(alias="pictureUrl", default=None) + issues: Optional[List[TaskMappingIssueDTO]] = None + + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class TaskStatusDTO(Model): + +class TaskStatusDTO(BaseModel): """Describes a DTO for the current status of the task""" - task_id = IntType(serialized_name="taskId") - task_status = StringType(serialized_name="taskStatus") - action_date = UTCDateTimeType(serialized_name="actionDate") - action_by = StringType(serialized_name="actionBy") + task_id: Optional[int] = Field(alias="taskId", default=None) + task_status: Optional[str] = Field(alias="taskStatus", default=None) + action_date: Optional[datetime] = Field(alias="actionDate", default=None) + action_by: Optional[str] = Field(alias="actionBy", default=None) + + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class TaskDTO(Model): + +class TaskDTO(BaseModel): """Describes a Task DTO""" - task_id = IntType(serialized_name="taskId") - project_id = IntType(serialized_name="projectId") - task_status = StringType(serialized_name="taskStatus") - lock_holder = StringType(serialized_name="lockHolder", serialize_when_none=False) - task_history = ListType(ModelType(TaskHistoryDTO), serialized_name="taskHistory") - task_annotations = ListType( - ModelType(TaskAnnotationDTO), serialized_name="taskAnnotation" + task_id: Optional[int] = Field(None, alias="taskId") + project_id: Optional[int] = Field(None, alias="projectId") + task_status: Optional[str] = Field(None, alias="taskStatus") + lock_holder: Optional[str] = Field( + None, alias="lockHolder", serialize_when_none=False + ) + task_history: Optional[List[TaskHistoryDTO]] = Field(None, alias="taskHistory") + task_annotations: Optional[List[TaskAnnotationDTO]] = Field( + None, alias="taskAnnotation" ) - per_task_instructions = StringType( - serialized_name="perTaskInstructions", serialize_when_none=False + per_task_instructions: Optional[str] = Field( + None, alias="perTaskInstructions", serialize_when_none=False ) - auto_unlock_seconds = IntType(serialized_name="autoUnlockSeconds") - last_updated = UTCDateTimeType( - serialized_name="lastUpdated", serialize_when_none=False + auto_unlock_seconds: Optional[int] = Field(None, alias="autoUnlockSeconds") + last_updated: Optional[datetime] = Field( + None, alias="lastUpdated", serialize_when_none=False ) - comments_number = IntType(serialized_name="numberOfComments") + comments_number: Optional[int] = Field(None, alias="numberOfComments") + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class TaskDTOs(Model): + +class TaskDTOs(BaseModel): """Describes an array of Task DTOs""" - tasks = ListType(ModelType(TaskDTO)) + tasks: Optional[List[TaskDTO]] = None -class TaskCommentDTO(Model): +class TaskCommentDTO(BaseModel): """Describes the model used to add a standalone comment to a task outside of mapping/validation""" - user_id = IntType(required=True) - comment = StringType(required=True) - task_id = IntType(required=True) - project_id = IntType(required=True) - preferred_locale = StringType(default="en") + user_id: int = Field(..., alias="userId") + comment: str + task_id: int = Field(..., alias="taskId") + project_id: int = Field(..., alias="projectId") + preferred_locale: str = Field("en") + + class Config: + populate_by_name = True -class ExtendLockTimeDTO(Model): +class ExtendLockTimeDTO(BaseModel): """DTO used to extend expiry time of tasks""" - project_id = IntType(required=True) - task_ids = ListType(IntType, required=True, serialized_name="taskIds") - user_id = IntType(required=True) + project_id: int + task_ids: List[int] = Field(alias="taskIds") + user_id: int + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/mapping_issues_dto.py b/backend/models/dtos/mapping_issues_dto.py index e718017c9d..f2663e70c4 100644 --- a/backend/models/dtos/mapping_issues_dto.py +++ b/backend/models/dtos/mapping_issues_dto.py @@ -1,30 +1,29 @@ -from schematics import Model -from schematics.types import IntType, StringType, BooleanType, ModelType -from schematics.types.compound import ListType +from typing import List, Optional +from pydantic import BaseModel, Field -class MappingIssueCategoryDTO(Model): + +class MappingIssueCategoryDTO(BaseModel): """DTO used to define a mapping-issue category""" - category_id = IntType(serialized_name="categoryId") - name = StringType(required=True) - description = StringType(required=False) - archived = BooleanType(required=False) + category_id: int = Field(None, alias="categoryId") + name: str = Field(None, alias="name") + description: str = Field(None, alias="description") + archived: bool = Field(False, alias="archived") -class MappingIssueCategoriesDTO(Model): +class MappingIssueCategoriesDTO(BaseModel): """DTO for all mapping-issue categories""" - def __init__(self): - super().__init__() - self.categories = [] - - categories = ListType(ModelType(MappingIssueCategoryDTO)) + categories: List[MappingIssueCategoryDTO] = Field([], alias="categories") -class TaskMappingIssueDTO(Model): +class TaskMappingIssueDTO(BaseModel): """DTO used to define a single mapping issue recorded with a task invalidation""" - category_id = IntType(serialized_name="categoryId") - name = StringType(required=True) - count = IntType(required=True) + category_id: Optional[int] = Field(alias="categoryId", default=None) + name: Optional[str] = None + count: Optional[int] = None + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/message_dto.py b/backend/models/dtos/message_dto.py index a9dbee231c..4a57557202 100644 --- a/backend/models/dtos/message_dto.py +++ b/backend/models/dtos/message_dto.py @@ -1,37 +1,34 @@ -from schematics import Model -from schematics.types import StringType, IntType, BooleanType, UTCDateTimeType -from schematics.types.compound import ListType, ModelType +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + from backend.models.dtos.stats_dto import Pagination -class MessageDTO(Model): +class MessageDTO(BaseModel): """DTO used to define a message that will be sent to a user""" - message_id = IntType(serialized_name="messageId") - subject = StringType( - serialized_name="subject", - required=True, - serialize_when_none=False, - min_length=1, - ) - message = StringType( - serialized_name="message", - required=True, - serialize_when_none=False, - min_length=1, - ) - from_user_id = IntType(required=True, serialize_when_none=False) - from_username = StringType(serialized_name="fromUsername", default="") - display_picture_url = StringType(serialized_name="displayPictureUrl", default="") - project_id = IntType(serialized_name="projectId") - project_title = StringType(serialized_name="projectTitle") - task_id = IntType(serialized_name="taskId") - message_type = StringType(serialized_name="messageType") - sent_date = UTCDateTimeType(serialized_name="sentDate") - read = BooleanType() - - -class MessagesDTO(Model): + message_id: Optional[int] = Field(None, alias="messageId") + subject: Optional[str] = Field(min_length=1, alias="subject") + message: Optional[str] = Field(min_length=1, alias="message") + from_user_id: Optional[int] = Field(alias="fromUserId") + from_username: Optional[str] = Field("", alias="fromUsername") + display_picture_url: Optional[str] = Field("", alias="displayPictureUrl") + project_id: Optional[int] = Field(None, alias="projectId") + project_title: Optional[str] = Field(None, alias="projectTitle") + task_id: Optional[int] = Field(None, alias="taskId") + message_type: Optional[str] = Field(None, alias="messageType") + sent_date: Optional[datetime] = Field(None, alias="sentDate") + read: Optional[bool] = None + + class Config: + populate_by_name = True + + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} + + +class MessagesDTO(BaseModel): """DTO used to return all user messages""" def __init__(self): @@ -39,23 +36,48 @@ def __init__(self): super().__init__() self.user_messages = [] - pagination = ModelType(Pagination) - user_messages = ListType(ModelType(MessageDTO), serialized_name="userMessages") + pagination: Optional[Pagination] = None + user_messages: Optional[List[MessageDTO]] = Field([], alias="userMessages") + class Config: + populate_by_name = True -class ChatMessageDTO(Model): + +class ChatMessageDTO(BaseModel): """DTO describing an individual project chat message""" - id = IntType(required=False, serialize_when_none=False) - message = StringType(required=True) - user_id = IntType(required=True, serialize_when_none=False) - project_id = IntType(required=True, serialize_when_none=False) - picture_url = StringType(default=None, serialized_name="pictureUrl") - timestamp = UTCDateTimeType() - username = StringType() + id: Optional[int] = Field(None, alias="id") + message: str = Field(required=True) + user_id: int = Field(required=True) + project_id: int = Field(required=True) + picture_url: str = Field(default=None, alias="pictureUrl") + timestamp: datetime + username: str + + class Config: + populate_by_name = True + + # json_encoders = { + # datetime: lambda v: v.isoformat() + "Z" if v else None + # } + + +class ListChatMessageDTO(BaseModel): + """DTO describing an individual project chat message""" + + id: Optional[int] = Field(None, alias="id") + message: str = Field(required=True) + picture_url: Optional[str] = Field(None, alias="pictureUrl") + timestamp: datetime + username: str + + class Config: + populate_by_name = True + + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class ProjectChatDTO(Model): +class ProjectChatDTO(BaseModel): """DTO describing all chat messages on one project""" def __init__(self): @@ -63,5 +85,5 @@ def __init__(self): super().__init__() self.chat = [] - chat = ListType(ModelType(ChatMessageDTO)) - pagination = ModelType(Pagination) + chat: Optional[List[ListChatMessageDTO]] = None + pagination: Optional[Pagination] = None diff --git a/backend/models/dtos/notification_dto.py b/backend/models/dtos/notification_dto.py index c2c35e4b16..178c55bfa9 100644 --- a/backend/models/dtos/notification_dto.py +++ b/backend/models/dtos/notification_dto.py @@ -1,10 +1,11 @@ -from schematics import Model -from schematics.types import IntType, UTCDateTimeType +from datetime import datetime +from pydantic import BaseModel, Field -class NotificationDTO(Model): + +class NotificationDTO(BaseModel): """DTO used to define a notification count that will be sent to a user""" - user_id = IntType(serialized_name="userId") - date = UTCDateTimeType(serialized_name="date") - unread_count = IntType(serialized_name="unreadCount") + user_id: int = Field(alias="userId") + date: datetime = Field(alias="date") + unread_count: int = Field(alias="unreadCount") diff --git a/backend/models/dtos/organisation_dto.py b/backend/models/dtos/organisation_dto.py index fef05b01d0..edd231250c 100644 --- a/backend/models/dtos/organisation_dto.py +++ b/backend/models/dtos/organisation_dto.py @@ -1,13 +1,8 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import ( - StringType, - IntType, - ListType, - ModelType, - BooleanType, - DictType, -) +from typing import Dict, List, Optional + +from fastapi import HTTPException +from pydantic import BaseModel, Field +from pydantic.functional_validators import field_validator from backend.models.dtos.stats_dto import OrganizationStatsDTO from backend.models.postgis.statuses import OrganisationType @@ -18,77 +13,120 @@ def is_known_organisation_type(value): try: OrganisationType[value.upper()] except (AttributeError, KeyError): - raise ValidationError( + raise HTTPException( f"Unknown organisationType: {value}. Valid values are {OrganisationType.FREE.name}, " f"{OrganisationType.DISCOUNTED.name}, {OrganisationType.FULL_FEE.name}" ) -class OrganisationManagerDTO(Model): - """Describes JSON model for a organisation manager""" +class OrganisationManagerDTO(BaseModel): + username: Optional[str] = None + picture_url: Optional[str] = Field(None, alias="pictureUrl") + + class Config: + populate_by_name = True - username = StringType(required=True) - picture_url = StringType(serialized_name="pictureUrl") +class OrganisationTeamsDTO(BaseModel): + team_id: Optional[int] = Field(None, alias="teamId") + name: Optional[str] = None + description: Optional[str] = None + join_method: Optional[str] = Field(None, alias="joinMethod") + visibility: Optional[str] = None + members: List[Dict[str, Optional[str]]] = Field(default=[]) -class OrganisationTeamsDTO(Model): - """Describes JSON model for a team. To be used in the Organisations endpoints.""" + class Config: + populate_by_name = True - team_id = IntType(serialized_name="teamId") - name = StringType(required=True) - description = StringType() - join_method = StringType(required=True, serialized_name="joinMethod") - visibility = StringType() - members = ListType(DictType(StringType, serialize_when_none=False)) +class OrganisationDTO(BaseModel): + organisation_id: Optional[int] = Field(None, alias="organisationId") + managers: Optional[List[OrganisationManagerDTO]] = None + name: Optional[str] = None + slug: Optional[str] = None + logo: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + is_manager: Optional[bool] = Field(None, alias="isManager") + projects: Optional[List[str]] = Field(default=[], alias="projects") + teams: List[OrganisationTeamsDTO] = None + campaigns: Optional[List[List[str]]] = None + stats: Optional[OrganizationStatsDTO] = None + type: Optional[str] = Field(None) + subscription_tier: Optional[int] = Field(None, alias="subscriptionTier") -class OrganisationDTO(Model): - """Describes JSON model for an organisation""" + class Config: + populate_by_name = True - organisation_id = IntType(serialized_name="organisationId") - managers = ListType(ModelType(OrganisationManagerDTO), min_size=1, required=True) - name = StringType(required=True) - slug = StringType() - logo = StringType() - description = StringType() - url = StringType() - is_manager = BooleanType(serialized_name="isManager") - projects = ListType(StringType, serialize_when_none=False) - teams = ListType(ModelType(OrganisationTeamsDTO)) - campaigns = ListType(ListType(StringType)) - stats = ModelType(OrganizationStatsDTO, serialize_when_none=False) - type = StringType(validators=[is_known_organisation_type]) - subscription_tier = IntType(serialized_name="subscriptionTier") + @field_validator("type", mode="before") + def validate_type(cls, value): + if value is None: + return value + return is_known_organisation_type(value) -class ListOrganisationsDTO(Model): +class ListOrganisationsDTO(BaseModel): def __init__(self): super().__init__() self.organisations = [] - organisations = ListType(ModelType(OrganisationDTO)) + organisations: Optional[List[OrganisationDTO]] = None -class NewOrganisationDTO(Model): +class NewOrganisationDTO(BaseModel): """Describes a JSON model to create a new organisation""" - organisation_id = IntType(serialized_name="organisationId", required=False) - managers = ListType(StringType(), required=True) - name = StringType(required=True) - slug = StringType() - logo = StringType() - description = StringType() - url = StringType() - type = StringType(validators=[is_known_organisation_type]) - subscription_tier = IntType(serialized_name="subscriptionTier") + organisation_id: Optional[int] = Field(None, alias="organisationId") + managers: List[str] + name: str + slug: Optional[str] = None + logo: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + type: str + subscription_tier: Optional[int] = Field(None, alias="subscriptionTier") + + class Config: + populate_by_name = True + + @field_validator("type", mode="before") + @classmethod + def validate_type(cls, value: Optional[str]) -> Optional[str]: + """Validates organisation subscription type string""" + try: + OrganisationType[value.upper()] + except (AttributeError, KeyError): + raise ValueError( + f"Unknown organisationType: {value}. Valid values are {OrganisationType.FREE.name}, " + f"{OrganisationType.DISCOUNTED.name}, {OrganisationType.FULL_FEE.name}" + ) + return value class UpdateOrganisationDTO(OrganisationDTO): - organisation_id = IntType(serialized_name="organisationId", required=False) - managers = ListType(StringType()) - name = StringType() - slug = StringType() - logo = StringType() - description = StringType() - url = StringType() - type = StringType(validators=[is_known_organisation_type]) + organisation_id: Optional[int] = Field(None, alias="organisationId") + managers: List[str] = Field(default=[]) + name: Optional[str] = None + slug: Optional[str] = None + logo: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + type: Optional[str] = None + + class Config: + populate_by_name = True + + @field_validator("type", mode="before") + @classmethod + def validate_type(cls, value: Optional[str]) -> Optional[str]: + """Validates organisation subscription type string""" + if value is None: + return value + try: + OrganisationType[value.upper()] + except (AttributeError, KeyError): + raise ValueError( + f"Unknown organisationType: {value}. Valid values are {OrganisationType.FREE.name}, " + f"{OrganisationType.DISCOUNTED.name}, {OrganisationType.FULL_FEE.name}" + ) + return value diff --git a/backend/models/dtos/partner_dto.py b/backend/models/dtos/partner_dto.py index bf9b2087df..e5307e0d11 100644 --- a/backend/models/dtos/partner_dto.py +++ b/backend/models/dtos/partner_dto.py @@ -1,19 +1,28 @@ -from schematics import Model -from schematics.types import StringType, ListType, LongType +import json +from typing import Dict, List, Optional +from pydantic import BaseModel -class PartnerDTO(Model): + +class PartnerDTO(BaseModel): """DTO for Partner""" - id = LongType() - name = StringType(serialized_name="name") - primary_hashtag = StringType(serialized_name="primary_hashtag") - secondary_hashtag = StringType(serialized_name="secondary_hashtag") - link_x = StringType(serialized_name="link_x") - link_meta = StringType(serialized_name="link_meta") - link_instagram = StringType(serialized_name="link_instagram") - logo_url = StringType(serialized_name="logo_url") - current_projects = StringType(serialized_name="current_projects") - permalink = StringType(serialized_name="permalink") - website_links = ListType(StringType, serialized_name="website_links") - mapswipe_group_id = StringType() + id: Optional[int] = None + name: str + primary_hashtag: str + secondary_hashtag: Optional[str] = None + link_x: Optional[str] = None + link_meta: Optional[str] = None + link_instagram: Optional[str] = None + logo_url: Optional[str] = None + current_projects: Optional[str] = None + permalink: Optional[str] = None + website_links: Optional[List[Dict]] = None + mapswipe_group_id: Optional[str] = None + + @classmethod + def from_record(cls, record): + record_dict = dict(record) + if record_dict.get("website_links"): + record_dict["website_links"] = json.loads(record_dict["website_links"]) + return cls(**record_dict) diff --git a/backend/models/dtos/partner_stats_dto.py b/backend/models/dtos/partner_stats_dto.py index 59b75430c2..20bba2179e 100644 --- a/backend/models/dtos/partner_stats_dto.py +++ b/backend/models/dtos/partner_stats_dto.py @@ -1,98 +1,122 @@ +from typing import List, Optional + import pandas as pd -from schematics import Model -from schematics.types import ( - StringType, - LongType, - IntType, - ListType, - ModelType, - FloatType, - BooleanType, -) +from pydantic import BaseModel, Field + + +class UserGroupMemberDTO(BaseModel): + """Describes a JSON model for a user group member.""" + + id: Optional[str] = None + user_id: Optional[str] = Field(None, alias="userId") + username: Optional[str] = None + is_active: Optional[bool] = Field(None, alias="isActive") + total_mapping_projects: Optional[int] = Field(None, alias="totalMappingProjects") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + + class Config: + populate_by_name = True + + +class OrganizationContributionsDTO(BaseModel): + """Describes a JSON model for organization contributions.""" + + organization_name: Optional[str] = Field(None, alias="organizationName") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + class Config: + populate_by_name = True -class UserGroupMemberDTO(Model): - id = StringType() - user_id = StringType(serialized_name="userId") - username = StringType() - is_active = BooleanType(serialized_name="isActive") - total_mapping_projects = IntType(serialized_name="totalMappingProjects") - total_contribution_time = IntType(serialized_name="totalcontributionTime") - total_contributions = IntType(serialized_name="totalcontributions") +class UserContributionsDTO(BaseModel): + """Describes a JSON model for user contributions.""" -class OrganizationContributionsDTO(Model): - organization_name = StringType(serialized_name="organizationName") - total_contributions = IntType(serialized_name="totalcontributions") + total_mapping_projects: Optional[int] = Field(None, alias="totalMappingProjects") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + username: Optional[str] = None + user_id: Optional[str] = Field(None, alias="userId") + class Config: + populate_by_name = True -class UserContributionsDTO(Model): - total_mapping_projects = IntType(serialized_name="totalMappingProjects") - total_contribution_time = IntType(serialized_name="totalcontributionTime") - total_contributions = IntType(serialized_name="totalcontributions") - username = StringType() - user_id = StringType(serialized_name="userId") +class GeojsonDTO(BaseModel): + type: Optional[str] = None + coordinates: Optional[List[float]] = None -class GeojsonDTO(Model): - type = StringType() - coordinates = ListType(FloatType) +class GeoContributionsDTO(BaseModel): + geojson: Optional[GeojsonDTO] = None + total_contributions: Optional[int] = Field(None, alias="totalcontributions") -class GeoContributionsDTO(Model): - geojson = ModelType(GeojsonDTO) - total_contributions = IntType(serialized_name="totalcontributions") + class Config: + populate_by_name = True -class ContributionsByDateDTO(Model): - task_date = StringType(serialized_name="taskDate") - total_contributions = IntType(serialized_name="totalcontributions") +class ContributionsByDateDTO(BaseModel): + task_date: str = Field(None, alias="taskDate") + total_contributions: int = Field(None, alias="totalcontributions") -class ContributionTimeByDateDTO(Model): - date = StringType(serialized_name="date") - total_contribution_time = IntType(serialized_name="totalcontributionTime") +class ContributionTimeByDateDTO(BaseModel): + date: str = Field(None, alias="date") + total_contribution_time: int = Field(None, alias="totalcontributionTime") + class Config: + populate_by_name = True -class ContributionsByProjectTypeDTO(Model): - project_type = StringType(serialized_name="projectType") - project_type_display = StringType(serialized_name="projectTypeDisplay") - total_contributions = IntType(serialized_name="totalcontributions") +class ContributionsByProjectTypeDTO(BaseModel): + project_type: str = Field(None, alias="projectType") + project_type_display: str = Field(None, alias="projectTypeDisplay") + total_contributions: int = Field(None, alias="totalcontributions") -class AreaSwipedByProjectTypeDTO(Model): - total_area = FloatType(serialized_name="totalArea") - project_type = StringType(serialized_name="projectType") - project_type_display = StringType(serialized_name="projectTypeDisplay") + class Config: + populate_by_name = True -class GroupedPartnerStatsDTO(Model): +class AreaSwipedByProjectTypeDTO(BaseModel): + total_area: Optional[float] = Field(None, alias="totalArea") + project_type: str = Field(None, alias="projectType") + project_type_display: str = Field(None, alias="projectTypeDisplay") + + class Config: + populate_by_name = True + + +class GroupedPartnerStatsDTO(BaseModel): """General statistics of a partner and its members.""" - id = LongType() - provider = StringType() - id_inside_provider = StringType(serialized_name="idInsideProvider") - name_inside_provider = StringType(serialized_name="nameInsideProvider") - description_inside_provider = StringType( - serialized_name="descriptionInsideProvider" + id: Optional[int] = None + provider: str + id_inside_provider: Optional[str] = Field(None, alias="idInsideProvider") + name_inside_provider: Optional[str] = Field(None, alias="nameInsideProvider") + description_inside_provider: Optional[str] = Field( + None, alias="descriptionInsideProvider" ) - members_count = IntType(serialized_name="membersCount") - members = ListType(ModelType(UserGroupMemberDTO)) + members_count: Optional[int] = Field(None, alias="membersCount") + members: List[UserGroupMemberDTO] = None # General stats of partner - total_contributors = IntType(serialized_name="totalContributors") - total_contributions = IntType(serialized_name="totalcontributions") - total_contribution_time = IntType(serialized_name="totalcontributionTime") + total_contributors: Optional[int] = Field(None, alias="totalContributors") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") # Recent contributions during the last 1 month - total_recent_contributors = IntType(serialized_name="totalRecentContributors") - total_recent_contributions = IntType(serialized_name="totalRecentcontributions") - total_recent_contribution_time = IntType( - serialized_name="totalRecentcontributionTime" + total_recent_contributors: Optional[int] = Field( + None, alias="totalRecentContributors" + ) + total_recent_contributions: Optional[int] = Field( + None, alias="totalRecentcontributions" + ) + total_recent_contribution_time: Optional[int] = Field( + None, alias="totalRecentcontributionTime" ) def to_csv(self): - df = pd.json_normalize(self.to_primitive()["members"]) + df = pd.json_normalize(self.dict(by_alias=True)["members"]) df.drop( columns=["id"], @@ -109,37 +133,40 @@ def to_csv(self): return df.to_csv(index=False) + class Config: + populate_by_name = True + -class FilteredPartnerStatsDTO(Model): +class FilteredPartnerStatsDTO(BaseModel): """Statistics of a partner contributions filtered by time range.""" - id = LongType() - provider = StringType() - id_inside_provider = StringType(serialized_name="idInsideProvider") + id: Optional[int] = None + provider: str + id_inside_provider: Optional[str] = Field(None, alias="idInsideProvider") - from_date = StringType(serialized_name="fromDate") - to_date = StringType(serialized_name="toDate") - contributions_by_user = ListType( - ModelType(UserContributionsDTO), serialized_name="contributionsByUser" + from_date: Optional[str] = Field(None, alias="fromDate") + to_date: Optional[str] = Field(None, alias="toDate") + contributions_by_user: List[UserContributionsDTO] = Field( + [], alias="contributionsByUser" ) - contributions_by_geo = ListType( - ModelType(GeoContributionsDTO), serialized_name="contributionsByGeo" + contributions_by_geo: List[GeoContributionsDTO] = Field( + [], alias="contributionsByGeo" ) - area_swiped_by_project_type = ListType( - ModelType(AreaSwipedByProjectTypeDTO), serialized_name="areaSwipedByProjectType" + area_swiped_by_project_type: List[AreaSwipedByProjectTypeDTO] = Field( + [], alias="areaSwipedByProjectType" ) - - contributions_by_project_type = ListType( - ModelType(ContributionsByProjectTypeDTO), - serialized_name="contributionsByProjectType", + contributions_by_project_type: List[ContributionsByProjectTypeDTO] = Field( + [], alias="contributionsByProjectType" ) - contributions_by_date = ListType( - ModelType(ContributionsByDateDTO), serialized_name="contributionsByDate" + contributions_by_date: List[ContributionsByDateDTO] = Field( + [], alias="contributionsByDate" ) - contributions_by_organization_name = ListType( - ModelType(OrganizationContributionsDTO), - serialized_name="contributionsByorganizationName", + contributions_by_organization_name: List[OrganizationContributionsDTO] = Field( + [], alias="contributionsByorganizationName" ) - contribution_time_by_date = ListType( - ModelType(ContributionTimeByDateDTO), serialized_name="contributionTimeByDate" + contribution_time_by_date: List[ContributionTimeByDateDTO] = Field( + [], alias="contributionTimeByDate" ) + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/project_dto.py b/backend/models/dtos/project_dto.py index 419d397e7f..345c4d2fa7 100644 --- a/backend/models/dtos/project_dto.py +++ b/backend/models/dtos/project_dto.py @@ -1,339 +1,386 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import ( - StringType, - BaseType, - IntType, - BooleanType, - FloatType, - UTCDateTimeType, - DateType, -) -from schematics.types.compound import ListType, ModelType -from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO +from datetime import date, datetime +from typing import Any, Dict, List, Optional, Union + +from fastapi import HTTPException +from pydantic import BaseModel, Field, root_validator, field_validator + +from backend.models.dtos.campaign_dto import CampaignDTO +from backend.models.dtos.interests_dto import InterestDTO from backend.models.dtos.stats_dto import Pagination +from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO from backend.models.dtos.team_dto import ProjectTeamDTO -from backend.models.dtos.interests_dto import InterestDTO from backend.models.postgis.statuses import ( - ProjectStatus, - ProjectPriority, - MappingTypes, - TaskCreationMode, Editors, MappingPermission, - ValidationPermission, + MappingTypes, ProjectDifficulty, + ProjectPriority, + ProjectStatus, + TaskCreationMode, + ValidationPermission, ) -from backend.models.dtos.campaign_dto import CampaignDTO - -def is_known_project_status(value): - """Validates that Project Status is known value""" - if isinstance(value, list): - return # Don't validate the entire list, just the individual values +def is_known_project_status(value: str) -> str: + """Validates that Project Status is a known value.""" try: ProjectStatus[value.upper()] except KeyError: - raise ValidationError( - f"Unknown projectStatus: {value} Valid values are {ProjectStatus.DRAFT.name}, " - f"{ProjectStatus.PUBLISHED.name}, {ProjectStatus.ARCHIVED.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown projectStatus: {value}. Valid values are: " + f"{ProjectStatus.DRAFT.name}, " + f"{ProjectStatus.PUBLISHED.name}, " + f"{ProjectStatus.ARCHIVED.name}." + ), ) + return value -def is_known_project_priority(value): - """Validates Project priority is known value""" +def is_known_project_priority(value: str) -> str: + """Validates that Project Priority is a known value.""" try: ProjectPriority[value.upper()] except KeyError: - raise ValidationError( - f"Unknown projectStatus: {value} Valid values are {ProjectPriority.LOW.name}, " - f"{ProjectPriority.MEDIUM.name}, {ProjectPriority.HIGH.name}, " - f"{ProjectPriority.URGENT.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown projectPriority: {value}. Valid values are: " + f"{ProjectPriority.LOW.name}, " + f"{ProjectPriority.MEDIUM.name}, " + f"{ProjectPriority.HIGH.name}, " + f"{ProjectPriority.URGENT.name}." + ), ) + return value -def is_known_mapping_type(value): - """Validates Mapping Type is known value""" - if isinstance(value, list): - return # Don't validate the entire list, just the individual values - +def is_known_mapping_type(value: str) -> str: + """Validates that Mapping Type is a known value.""" try: MappingTypes[value.upper()] except KeyError: - raise ValidationError( - f"Unknown mappingType: {value} Valid values are {MappingTypes.ROADS.name}, " - f"{MappingTypes.BUILDINGS.name}, {MappingTypes.WATERWAYS.name}, " - f"{MappingTypes.LAND_USE.name}, {MappingTypes.OTHER.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown mappingType: {value}. Valid values are: " + f"{MappingTypes.ROADS.name}, " + f"{MappingTypes.BUILDINGS.name}, " + f"{MappingTypes.WATERWAYS.name}, " + f"{MappingTypes.LAND_USE.name}, " + f"{MappingTypes.OTHER.name}." + ), ) + return value -def is_known_editor(value): - """Validates Editor is known value""" - if isinstance(value, list): - return # Don't validate the entire list, just the individual values - +def is_known_editor(value: str) -> str: + """Validates that Editor is a known value.""" try: Editors[value.upper()] except KeyError: - raise ValidationError( - f"Unknown editor: {value} Valid values are {Editors.ID.name}, " - f"{Editors.JOSM.name}, {Editors.POTLATCH_2.name}, " - f"{Editors.FIELD_PAPERS.name}, " - f"{Editors.RAPID.name} " + raise HTTPException( + status_code=400, + detail=( + f"Unknown editor: {value}. Valid values are: " + f"{Editors.ID.name}, " + f"{Editors.JOSM.name}, " + f"{Editors.POTLATCH_2.name}, " + f"{Editors.FIELD_PAPERS.name}, " + f"{Editors.RAPID.name}." + ), ) + return value -def is_known_task_creation_mode(value): - """Validates Task Creation Mode is known value""" +def is_known_task_creation_mode(value: str) -> str: + """Validates that Task Creation Mode is a known value.""" try: TaskCreationMode[value.upper()] except KeyError: - raise ValidationError( - f"Unknown taskCreationMode: {value} Valid values are {TaskCreationMode.GRID.name}, " - f"{TaskCreationMode.ARBITRARY.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown taskCreationMode: {value}. Valid values are: " + f"{TaskCreationMode.GRID.name}, " + f"{TaskCreationMode.ARBITRARY.name}." + ), ) + return value -def is_known_mapping_permission(value): - """Validates Mapping Permission String""" +def is_known_mapping_permission(value: str) -> str: + """Validates that Mapping Permission is a known value.""" try: MappingPermission[value.upper()] except KeyError: - raise ValidationError( - f"Unknown mappingPermission: {value} Valid values are {MappingPermission.ANY.name}, " - f"{MappingPermission.LEVEL.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown mappingPermission: {value}. Valid values are: " + f"{MappingPermission.ANY.name}, " + f"{MappingPermission.LEVEL.name}." + ), ) + return value -def is_known_validation_permission(value): - """Validates Validation Permission String""" +def is_known_validation_permission(value: str) -> str: + """Validates that Validation Permission is a known value.""" try: ValidationPermission[value.upper()] except KeyError: - raise ValidationError( - f"Unknown validationPermission: {value} Valid values are {ValidationPermission.ANY.name}, " - f"{ValidationPermission.LEVEL.name}, {ValidationPermission.TEAMS.name}, " - f"{ValidationPermission.TEAMS_LEVEL.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown validationPermission: {value}. Valid values are: " + f"{ValidationPermission.ANY.name}, " + f"{ValidationPermission.LEVEL.name}, " + f"{ValidationPermission.TEAMS.name}, " + f"{ValidationPermission.TEAMS_LEVEL.name}." + ), ) + return value -def is_known_project_difficulty(value): - """Validates that supplied project difficulty is known value""" +def is_known_project_difficulty(value: str) -> str: + """Validates that Project Difficulty is a known value.""" if value.upper() == "ALL": - return True + return value try: - value = value.split(",") - for difficulty in value: + value_list = value.split(",") + for difficulty in value_list: ProjectDifficulty[difficulty.upper()] except KeyError: - raise ValidationError( - f"Unknown projectDifficulty: {value} Valid values are {ProjectDifficulty.EASY.name}, " - f"{ProjectDifficulty.MODERATE.name}, {ProjectDifficulty.CHALLENGING.name} and ALL." + raise HTTPException( + status_code=400, + detail=( + f"Unknown projectDifficulty: {value}. Valid values are: " + f"{ProjectDifficulty.EASY.name}, " + f"{ProjectDifficulty.MODERATE.name}, " + f"{ProjectDifficulty.CHALLENGING.name}, and ALL." + ), ) + return value -class DraftProjectDTO(Model): +class DraftProjectDTO(BaseModel): """Describes JSON model used for creating draft project""" - cloneFromProjectId = IntType(serialized_name="cloneFromProjectId") - project_name = StringType(required=True, serialized_name="projectName") - organisation = IntType(required=True) - area_of_interest = BaseType(required=True, serialized_name="areaOfInterest") - tasks = BaseType(required=False) - has_arbitrary_tasks = BooleanType(required=True, serialized_name="arbitraryTasks") - user_id = IntType(required=True) + cloneFromProjectId: int = Field(None, alias="cloneFromProjectId") + project_name: str = Field(..., alias="projectName") + organisation: int = Field(None) + area_of_interest: dict = Field({}, alias="areaOfInterest") + tasks: Optional[dict] = Field({}) + has_arbitrary_tasks: bool = Field(False, alias="arbitraryTasks") + user_id: int = Field(None) + + class Config: + populate_by_name = True -class ProjectInfoDTO(Model): +class ProjectInfoDTO(BaseModel): """Contains the localized project info""" - locale = StringType(required=True) - name = StringType(default="") - short_description = StringType(serialized_name="shortDescription", default="") - description = StringType(default="") - instructions = StringType(default="") - per_task_instructions = StringType( - default="", serialized_name="perTaskInstructions" + locale: str + name: Optional[str] = "" + short_description: Optional[str] = Field(default="", alias="shortDescription") + description: Optional[str] = "" + instructions: Optional[str] = "" + per_task_instructions: Optional[str] = Field( + default="", alias="perTaskInstructions" ) + class Config: + populate_by_name = True -class CustomEditorDTO(Model): - """DTO to define a custom editor""" + @root_validator(pre=True) + def replace_none_with_empty_string(cls, values): + return { + key: (value if value is not None or key == "locale" else "") + for key, value in values.items() + } - name = StringType(required=True) - description = StringType() - url = StringType(required=True) +class CustomEditorDTO(BaseModel): + """DTO to define a custom editor""" -class ProjectDTO(Model): - """Describes JSON model for a tasking manager project""" + name: str = Field(None) + description: Optional[str] = Field(None) + url: str = Field(None) - project_id = IntType(serialized_name="projectId") - project_status = StringType( - required=True, - serialized_name="status", - validators=[is_known_project_status], - serialize_when_none=False, - ) - project_priority = StringType( - required=True, - serialized_name="projectPriority", - validators=[is_known_project_priority], - serialize_when_none=False, - ) - area_of_interest = BaseType(serialized_name="areaOfInterest") - aoi_bbox = ListType(FloatType, serialized_name="aoiBBOX") - tasks = BaseType(serialize_when_none=False) - default_locale = StringType( - required=True, serialized_name="defaultLocale", serialize_when_none=False - ) - project_info = ModelType( - ProjectInfoDTO, serialized_name="projectInfo", serialize_when_none=False - ) - project_info_locales = ListType( - ModelType(ProjectInfoDTO), - serialized_name="projectInfoLocales", - serialize_when_none=False, - ) - difficulty = StringType( - required=True, - serialized_name="difficulty", - validators=[is_known_project_difficulty], - ) - mapping_permission = StringType( - required=True, - serialized_name="mappingPermission", - validators=[is_known_mapping_permission], - ) - validation_permission = StringType( - required=True, - serialized_name="validationPermission", - validators=[is_known_validation_permission], - ) - enforce_random_task_selection = BooleanType( - required=False, default=False, serialized_name="enforceRandomTaskSelection" - ) - - private = BooleanType(required=True) - changeset_comment = StringType(serialized_name="changesetComment") - osmcha_filter_id = StringType(serialized_name="osmchaFilterId") - due_date = UTCDateTimeType(serialized_name="dueDate") - imagery = StringType() - josm_preset = StringType(serialized_name="josmPreset", serialize_when_none=False) - id_presets = ListType(StringType, serialized_name="idPresets", default=[]) - extra_id_params = StringType(serialized_name="extraIdParams") - rapid_power_user = BooleanType( - serialized_name="rapidPowerUser", default=False, required=False - ) - mapping_types = ListType( - StringType, - serialized_name="mappingTypes", - default=[], - validators=[is_known_mapping_type], - ) - campaigns = ListType(ModelType(CampaignDTO), default=[]) - organisation = IntType(required=True) - organisation_name = StringType(serialized_name="organisationName") - organisation_slug = StringType(serialized_name="organisationSlug") - organisation_logo = StringType(serialized_name="organisationLogo") - country_tag = ListType(StringType, serialized_name="countryTag") - - license_id = IntType(serialized_name="licenseId") - allowed_usernames = ListType( - StringType(), serialized_name="allowedUsernames", default=[] - ) - priority_areas = BaseType(serialized_name="priorityAreas") - created = UTCDateTimeType() - last_updated = UTCDateTimeType(serialized_name="lastUpdated") - author = StringType() - active_mappers = IntType(serialized_name="activeMappers") - percent_mapped = IntType(serialized_name="percentMapped") - percent_validated = IntType(serialized_name="percentValidated") - percent_bad_imagery = IntType(serialized_name="percentBadImagery") - task_creation_mode = StringType( - required=True, - serialized_name="taskCreationMode", - validators=[is_known_task_creation_mode], - serialize_when_none=False, - ) - project_teams = ListType(ModelType(ProjectTeamDTO), serialized_name="teams") - mapping_editors = ListType( - StringType, - min_size=1, - required=True, - serialized_name="mappingEditors", - validators=[is_known_editor], - ) - validation_editors = ListType( - StringType, - min_size=1, - required=True, - serialized_name="validationEditors", - validators=[is_known_editor], - ) - custom_editor = ModelType( - CustomEditorDTO, serialized_name="customEditor", serialize_when_none=False - ) - interests = ListType(ModelType(InterestDTO)) +class ProjectDTO(BaseModel): + """Describes JSON model for a tasking manager project""" -class ProjectFavoriteDTO(Model): + project_id: Optional[int] = Field(None, alias="projectId") + project_status: str = Field(alias="status") + project_priority: str = Field(alias="projectPriority") + area_of_interest: Optional[dict] = Field(None, alias="areaOfInterest") + aoi_bbox: Optional[List[float]] = Field(None, alias="aoiBBOX") + tasks: Optional[dict] = None + default_locale: str = Field(alias="defaultLocale") + project_info: Optional[ProjectInfoDTO] = Field(None, alias="projectInfo") + project_info_locales: Optional[List[ProjectInfoDTO]] = Field( + None, alias="projectInfoLocales" + ) + difficulty: str = Field(alias="difficulty") + mapping_permission: str = Field(alias="mappingPermission") + validation_permission: str = Field(alias="validationPermission") + enforce_random_task_selection: Optional[bool] = Field( + False, alias="enforceRandomTaskSelection" + ) + private: bool + changeset_comment: Optional[str] = Field(None, alias="changesetComment") + osmcha_filter_id: Optional[str] = Field(None, alias="osmchaFilterId") + due_date: Optional[datetime] = Field(None, alias="dueDate") + imagery: Optional[str] = None + josm_preset: Optional[str] = Field(None, alias="josmPreset") + id_presets: Optional[List[str]] = Field(default=[], alias="idPresets") + extra_id_params: Optional[str] = Field(None, alias="extraIdParams") + rapid_power_user: Optional[bool] = Field(False, alias="rapidPowerUser") + mapping_types: List[str] = Field(default=[], alias="mappingTypes") + campaigns: List[CampaignDTO] = Field(default=[]) + organisation: Optional[int] = None + organisation_name: Optional[str] = Field(None, alias="organisationName") + organisation_slug: Optional[str] = Field(None, alias="organisationSlug") + organisation_logo: Optional[str] = Field(None, alias="organisationLogo") + country_tag: Optional[List[str]] = Field(None, alias="countryTag") + license_id: Optional[int] = Field(None, alias="licenseId") + allowed_usernames: Optional[List[str]] = Field(default=[], alias="allowedUsernames") + priority_areas: Optional[List[Dict]] = Field(None, alias="priorityAreas") + created: Optional[datetime] = None + last_updated: Optional[datetime] = Field(None, alias="lastUpdated") + author: Optional[str] = None + active_mappers: Optional[int] = Field(None, alias="activeMappers") + percent_mapped: Optional[int] = Field(None, alias="percentMapped") + percent_validated: Optional[int] = Field(None, alias="percentValidated") + percent_bad_imagery: Optional[int] = Field(None, alias="percentBadImagery") + task_creation_mode: str = Field(alias="taskCreationMode") + project_teams: Optional[List[ProjectTeamDTO]] = Field(None, alias="teams") + mapping_editors: List[str] = Field(alias="mappingEditors") + validation_editors: List[str] = Field(alias="validationEditors") + custom_editor: Optional[CustomEditorDTO] = Field(None, alias="customEditor") + interests: Optional[List[InterestDTO]] = None + + class Config: + populate_by_name = True + + @field_validator("project_status") + @classmethod + def validate_project_status(cls, value: str) -> str: + if not is_known_project_status(value): + raise ValueError("Invalid project status") + return value + + @field_validator("project_priority") + @classmethod + def validate_project_priority(cls, value: str) -> str: + if not is_known_project_priority(value): + raise ValueError("Invalid project priority") + return value + + @field_validator("difficulty") + @classmethod + def validate_difficulty(cls, value: str) -> str: + if not is_known_project_difficulty(value): + raise ValueError("Invalid project difficulty") + return value + + @field_validator("mapping_permission") + @classmethod + def validate_mapping_permission(cls, value: str) -> str: + if not is_known_mapping_permission(value): + raise ValueError("Invalid mapping permission") + return value + + @field_validator("validation_permission") + @classmethod + def validate_validation_permission(cls, value: str) -> str: + if not is_known_validation_permission(value): + raise ValueError("Invalid validation permission") + return value + + @field_validator("mapping_types", mode="before") + @classmethod + def validate_mapping_types(cls, values: List[str]) -> List[str]: + if not all(is_known_mapping_type(v) for v in values): + raise ValueError("Invalid mapping type") + return values + + @field_validator("task_creation_mode") + @classmethod + def validate_task_creation_mode(cls, value: str) -> str: + if not is_known_task_creation_mode(value): + raise ValueError("Invalid task creation mode") + return value + + @field_validator("mapping_editors", "validation_editors", mode="before") + @classmethod + def validate_editors(cls, values: List[str]) -> List[str]: + if not all(is_known_editor(v) for v in values): + raise ValueError("Invalid editor") + return values + + +class ProjectFavoriteDTO(BaseModel): """DTO used to favorite a project""" - project_id = IntType(required=True) - user_id = IntType(required=True) + project_id: int + user_id: int -class ProjectFavoritesDTO(Model): - """DTO to retrieve favorited projects""" +class ProjectFavoritesDTO(BaseModel): + def __init__(self, favorited_projects: List[ProjectDTO] = None, **kwargs): + super().__init__(**kwargs) + self.favorited_projects = favorited_projects or [] - def __init__(self): - super().__init__() - self.favorited_projects = [] + favorited_projects: List[ProjectDTO] = Field(default=[], alias="favoritedProjects") - favorited_projects = ListType( - ModelType(ProjectDTO), serialized_name="favoritedProjects" - ) + class Config: + populate_by_name = True -class ProjectSearchDTO(Model): +class ProjectSearchDTO(BaseModel): """Describes the criteria users use to filter active projects""" - preferred_locale = StringType(default="en") - difficulty = StringType(validators=[is_known_project_difficulty]) - action = StringType() - mapping_types = ListType(StringType, validators=[is_known_mapping_type]) - mapping_types_exact = BooleanType(required=False) - project_statuses = ListType(StringType, validators=[is_known_project_status]) - organisation_name = StringType() - organisation_id = IntType() - team_id = IntType() - campaign = StringType() - order_by = StringType() - order_by_type = StringType() - country = StringType() - page = IntType(required=True) - text_search = StringType() - mapping_editors = ListType(StringType, validators=[is_known_editor]) - validation_editors = ListType(StringType, validators=[is_known_editor]) - teams = ListType(StringType()) - interests = ListType(IntType()) - created_by = IntType(required=False) - mapped_by = IntType(required=False) - favorited_by = IntType(required=False) - managed_by = IntType(required=False) - based_on_user_interests = IntType(required=False) - omit_map_results = BooleanType(required=False) - last_updated_lte = StringType(required=False) - last_updated_gte = StringType(required=False) - created_lte = StringType(required=False) - created_gte = StringType(required=False) - partner_id = IntType(required=False) - partnership_from = StringType(required=False) - partnership_to = StringType(required=False) - download_as_csv = BooleanType(required=False) + preferred_locale: Optional[str] = "en" + difficulty: Optional[str] = Field(None, validators=[is_known_project_difficulty]) + action: Optional[str] = None + mapping_types: Optional[List[str]] = Field(None, validators=[is_known_mapping_type]) + mapping_types_exact: Optional[bool] = None + project_statuses: Optional[List[str]] = Field( + None, validators=[is_known_project_status] + ) + organisation_name: Optional[str] = None + organisation_id: Optional[int] = None + team_id: Optional[int] = None + campaign: Optional[str] = None + order_by: Optional[str] = None + order_by_type: Optional[str] = None + country: Optional[str] = None + page: Optional[int] = None + text_search: Optional[str] = None + mapping_editors: Optional[List] = Field(None, validators=[is_known_editor]) + validation_editors: Optional[List] = Field(None, validators=[is_known_editor]) + teams: Optional[List[str]] = None + interests: Optional[List[int]] = None + created_by: Optional[int] = None + mapped_by: Optional[int] = None + favorited_by: Optional[int] = None + managed_by: Optional[int] = None + based_on_user_interests: Optional[int] = None + omit_map_results: Optional[bool] = None + last_updated_lte: Optional[str] = None + last_updated_gte: Optional[str] = None + created_lte: Optional[str] = None + created_gte: Optional[str] = None + partner_id: Optional[int] = None + partnership_from: Optional[str] = None + partnership_to: Optional[str] = None + download_as_csv: Optional[bool] = None def __hash__(self): """Make object hashable so we can cache user searches""" @@ -378,77 +425,82 @@ def __hash__(self): ) -class ProjectSearchBBoxDTO(Model): - bbox = ListType(FloatType, required=True, min_size=4, max_size=4) - input_srid = IntType(required=True, choices=[4326]) - preferred_locale = StringType(required=False, default="en") - project_author = IntType(required=False, serialized_name="projectAuthor") - - -class ListSearchResultDTO(Model): - """Describes one search result""" - - project_id = IntType(required=True, serialized_name="projectId") - locale = StringType(required=True) - name = StringType(default="") - short_description = StringType(serialized_name="shortDescription", default="") - difficulty = StringType(required=True, serialized_name="difficulty") - priority = StringType(required=True) - organisation_name = StringType(serialized_name="organisationName") - organisation_logo = StringType(serialized_name="organisationLogo") - campaigns = ListType(ModelType(CampaignDTO), default=[]) - percent_mapped = IntType(serialized_name="percentMapped") - percent_validated = IntType(serialized_name="percentValidated") - status = StringType(serialized_name="status") - active_mappers = IntType(serialized_name="activeMappers") - last_updated = UTCDateTimeType(serialized_name="lastUpdated") - due_date = UTCDateTimeType(serialized_name="dueDate") - total_contributors = IntType(serialized_name="totalContributors") - country = StringType(serialize_when_none=False) - - creation_date = UTCDateTimeType(serialized_name="creationDate", required=True) - author = StringType(serialize_when_none=False) - partner_names = ListType(StringType, serialized_name="partnerNames") - total_area = FloatType(required=True, serialized_name="totalAreaSquareKilometers") - - -class ProjectSearchResultsDTO(Model): +class ProjectSearchBBoxDTO(BaseModel): + bbox: List[float] = Field(..., min_items=4, max_items=4) + input_srid: int = Field(..., choices=[4326]) + preferred_locale: Optional[str] = Field(default="en") + project_author: Optional[int] = Field(default=None, alias="projectAuthor") + + class Config: + populate_by_name = True + + +class ListSearchResultDTO(BaseModel): + project_id: Optional[int] = Field(alias="projectId", default=None) + locale: Optional[str] = None + name: Optional[str] = Field(default="") + short_description: str = Field(default="", alias="shortDescription") + difficulty: Optional[str] = None + priority: Optional[str] = None + organisation_name: Optional[str] = Field(alias="organisationName", default=None) + organisation_logo: Optional[str] = Field(alias="organisationLogo", default=None) + campaigns: Optional[List[CampaignDTO]] = Field(default=[]) + percent_mapped: Optional[int] = Field(alias="percentMapped", default=None) + percent_validated: Optional[int] = Field(alias="percentValidated", default=None) + status: Optional[str] = None + active_mappers: Optional[int] = Field(alias="activeMappers", default=None) + last_updated: Optional[datetime] = Field(alias="lastUpdated", default=None) + due_date: Optional[datetime] = Field(alias="dueDate", default=None) + total_contributors: Optional[int] = Field(alias="totalContributors", default=None) + country: Optional[List[str]] = Field(default=None) + + # csv fields + creation_date: Optional[datetime] = Field(alias="creationDate", default=None) + author: Optional[str] = None + partner_names: Optional[List[str]] = Field(default=None, alias="partnerNames") + total_area: Optional[float] = Field(None, alias="totalAreaSquareKilometers") + + class Config: + populate_by_name = True + + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} + + +class ProjectSearchResultsDTO(BaseModel): """Contains all results for the search criteria""" - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.results = [] - self.map_results = [] + map_results: Optional[Any] = Field(default_factory=list, alias="mapResults") + results: Optional[List["ListSearchResultDTO"]] = Field(default_factory=list) + pagination: Optional["Pagination"] = Field(default_factory=dict) - map_results = BaseType(serialized_name="mapResults") - results = ListType(ModelType(ListSearchResultDTO)) - pagination = ModelType(Pagination) + class Config: + populate_by_name = True -class LockedTasksForUser(Model): +class LockedTasksForUser(BaseModel): """Describes all tasks locked by an individual user""" - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.locked_tasks = [] + locked_tasks: Optional[List[int]] = Field([], alias="lockedTasks") + project: Optional[int] = Field(None, alias="projectId") + task_status: Optional[str] = Field(None, alias="taskStatus") - locked_tasks = ListType(IntType, serialized_name="lockedTasks") - project = IntType(serialized_name="projectId") - task_status = StringType(serialized_name="taskStatus") + class Config: + populate_by_name = True -class ProjectComment(Model): +class ProjectComment(BaseModel): """Describes an individual user comment on a project task""" - comment = StringType() - comment_date = UTCDateTimeType(serialized_name="commentDate") - user_name = StringType(serialized_name="userName") - task_id = IntType(serialized_name="taskId") + comment: str + comment_date: datetime = Field(alias="commentDate") + user_name: str = Field(alias="userName") + task_id: int = Field(alias="taskId") + class Config: + populate_by_name = True -class ProjectCommentsDTO(Model): + +class ProjectCommentsDTO(BaseModel): """Contains all comments on a project""" def __init__(self): @@ -456,163 +508,130 @@ def __init__(self): super().__init__() self.comments = [] - comments = ListType(ModelType(ProjectComment)) + comments: List[ProjectComment] -class ProjectContribDTO(Model): - date = DateType(required=True) - mapped = IntType(required=True) - validated = IntType(required=True) - cumulative_mapped = IntType(required=False) - cumulative_validated = IntType(required=False) - total_tasks = IntType(required=False) +class ProjectContribDTO(BaseModel): + date: date + mapped: int + validated: int + cumulative_mapped: Optional[int] = None + cumulative_validated: Optional[int] = None + total_tasks: Optional[int] = None -class ProjectContribsDTO(Model): +class ProjectContribsDTO(BaseModel): """Contains all contributions on a project by day""" - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.mapping = [] - self.validation = [] - - stats = ListType(ModelType(ProjectContribDTO)) - - -class ProjectSummary(Model): - """Model used for PM dashboard""" - - project_id = IntType(required=True, serialized_name="projectId") - default_locale = StringType(serialized_name="defaultLocale") - author = StringType() - created = UTCDateTimeType() - due_date = UTCDateTimeType(serialized_name="dueDate") - last_updated = UTCDateTimeType(serialized_name="lastUpdated") - priority = StringType(serialized_name="projectPriority") - campaigns = ListType(ModelType(CampaignDTO), default=[]) - organisation = IntType() - organisation_name = StringType(serialized_name="organisationName") - organisation_slug = StringType(serialized_name="organisationSlug") - organisation_logo = StringType(serialized_name="organisationLogo") - country_tag = ListType(StringType, serialized_name="countryTag") - osmcha_filter_id = StringType(serialized_name="osmchaFilterId") - mapping_types = ListType( - StringType, serialized_name="mappingTypes", validators=[is_known_mapping_type] - ) - - changeset_comment = StringType(serialized_name="changesetComment") - percent_mapped = IntType(serialized_name="percentMapped") - percent_validated = IntType(serialized_name="percentValidated") - percent_bad_imagery = IntType(serialized_name="percentBadImagery") - aoi_centroid = BaseType(serialized_name="aoiCentroid") - difficulty = StringType(serialized_name="difficulty") - mapping_permission = IntType( - serialized_name="mappingPermission", validators=[is_known_mapping_permission] - ) - validation_permission = IntType( - serialized_name="validationPermission", - validators=[is_known_validation_permission], - ) - allowed_usernames = ListType( - StringType(), serialized_name="allowedUsernames", default=[] - ) - random_task_selection_enforced = BooleanType( - required=False, default=False, serialized_name="enforceRandomTaskSelection" - ) - private = BooleanType(serialized_name="private") - allowed_users = ListType(StringType, serialized_name="allowedUsernames", default=[]) - project_teams = ListType(ModelType(ProjectTeamDTO), serialized_name="teams") - project_info = ModelType( - ProjectInfoDTO, serialized_name="projectInfo", serialize_when_none=False - ) - short_description = StringType(serialized_name="shortDescription") - status = StringType() - imagery = StringType() - license_id = IntType(serialized_name="licenseId") - id_presets = ListType(StringType, serialized_name="idPresets", default=[]) - extra_id_params = StringType(serialized_name="extraIdParams") - rapid_power_user = BooleanType( - serialized_name="rapidPowerUser", default=False, required=False - ) - mapping_editors = ListType( - StringType, - min_size=1, - required=True, - serialized_name="mappingEditors", - validators=[is_known_editor], - ) - validation_editors = ListType( - StringType, - min_size=1, - required=True, - serialized_name="validationEditors", - validators=[is_known_editor], - ) - custom_editor = ModelType( - CustomEditorDTO, serialized_name="customEditor", serialize_when_none=False - ) - - -class PMDashboardDTO(Model): + stats: Optional[List[ProjectContribDTO]] = None + + +class ProjectSummary(BaseModel): + project_id: int = Field(..., alias="projectId") + default_locale: Optional[str] = Field(None, alias="defaultLocale") + author: Optional[str] = None + created: Optional[datetime] = None + due_date: Optional[datetime] = Field(None, alias="dueDate") + last_updated: Optional[datetime] = Field(None, alias="lastUpdated") + priority: Optional[str] = Field(None, alias="projectPriority") + campaigns: List[CampaignDTO] = Field(default_factory=list) + organisation: Optional[int] = None + organisation_name: Optional[str] = Field(None, alias="organisationName") + organisation_slug: Optional[str] = Field(None, alias="organisationSlug") + organisation_logo: Optional[str] = Field(None, alias="organisationLogo") + country_tag: List[str] = Field(default_factory=list, alias="countryTag") + osmcha_filter_id: Optional[str] = Field(None, alias="osmchaFilterId") + mapping_types: List[str] = Field(default_factory=list, alias="mappingTypes") + changeset_comment: Optional[str] = Field(None, alias="changesetComment") + percent_mapped: Optional[int] = Field(None, alias="percentMapped") + percent_validated: Optional[int] = Field(None, alias="percentValidated") + percent_bad_imagery: Optional[int] = Field(None, alias="percentBadImagery") + aoi_centroid: Optional[Union[dict, None]] = Field(None, alias="aoiCentroid") + difficulty: Optional[str] = Field(None, alias="difficulty") + mapping_permission: Optional[str] = Field(None, alias="mappingPermission") + validation_permission: Optional[str] = Field(None, alias="validationPermission") + allowed_usernames: List[str] = Field(default_factory=list, alias="allowedUsernames") + random_task_selection_enforced: bool = Field( + default=False, alias="enforceRandomTaskSelection" + ) + private: Optional[bool] = Field(None, alias="private") + allowed_users: List[str] = Field(default_factory=list, alias="allowedUsernames") + project_teams: List[ProjectTeamDTO] = Field(default_factory=list, alias="teams") + project_info: Optional[ProjectInfoDTO] = Field(None, alias="projectInfo") + short_description: Optional[str] = Field(None, alias="shortDescription") + status: Optional[str] = None + imagery: Optional[str] = None + license_id: Optional[int] = Field(None, alias="licenseId") + id_presets: List[str] = Field(default_factory=list, alias="idPresets") + extra_id_params: Optional[str] = Field(None, alias="extraIdParams") + rapid_power_user: bool = Field(default=False, alias="rapidPowerUser") + mapping_editors: List[str] = Field(..., min_items=1, alias="mappingEditors") + validation_editors: List[str] = Field(..., min_items=1, alias="validationEditors") + custom_editor: Optional[CustomEditorDTO] = Field(None, alias="customEditor") + + class Config: + populate_by_name = True + + +class PMDashboardDTO(BaseModel): """DTO for constructing the PM Dashboard""" - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.draft_projects = [] - self.archived_projects = [] - self.active_projects = [] - - draft_projects = ListType( - ModelType(ProjectSummary), serialized_name="draftProjects" + draft_projects: Optional[List[ProjectSummary]] = Field( + default_factory=list, alias="draftProjects" ) - active_projects = ListType( - ModelType(ProjectSummary), serialized_name="activeProjects" + active_projects: Optional[List[ProjectSummary]] = Field( + default_factory=list, alias="activeProjects" ) - archived_projects = ListType( - ModelType(ProjectSummary), serialized_name="archivedProjects" + archived_projects: Optional[List[ProjectSummary]] = Field( + default_factory=list, alias="archivedProjects" ) + class Config: + populate_by_name = True -class ProjectTaskAnnotationsDTO(Model): - """DTO for task annotations of a project""" - def __init__(self): - """DTO constructor set task arrays to empty""" - super().__init__() - self.tasks = [] +class ProjectTaskAnnotationsDTO(BaseModel): + """DTO for all task annotations on a project""" - project_id = IntType(required=True, serialized_name="projectId") - tasks = ListType( - ModelType(TaskAnnotationDTO), required=True, serialized_name="tasks" - ) + project_id: Optional[int] = Field(None, alias="projectId") + tasks: Optional[List[TaskAnnotationDTO]] = Field(default_factory=list) + class Config: + populate_by_name = True -class ProjectStatsDTO(Model): + +class ProjectStatsDTO(BaseModel): """DTO for detailed stats on a project""" - project_id = IntType(required=True, serialized_name="projectId") - area = FloatType(serialized_name="projectArea(in sq.km)") - total_mappers = IntType(serialized_name="totalMappers") - total_tasks = IntType(serialized_name="totalTasks") - total_comments = IntType(serialized_name="totalComments") - total_mapping_time = IntType(serialized_name="totalMappingTime") - total_validation_time = IntType(serialized_name="totalValidationTime") - total_time_spent = IntType(serialized_name="totalTimeSpent") - average_mapping_time = IntType(serialized_name="averageMappingTime") - average_validation_time = IntType(serialized_name="averageValidationTime") - percent_mapped = IntType(serialized_name="percentMapped") - percent_validated = IntType(serialized_name="percentValidated") - percent_bad_imagery = IntType(serialized_name="percentBadImagery") - aoi_centroid = BaseType(serialized_name="aoiCentroid") - time_to_finish_mapping = IntType(serialized_name="timeToFinishMapping") - time_to_finish_validating = IntType(serialized_name="timeToFinishValidating") - - -class ProjectUserStatsDTO(Model): + project_id: Optional[int] = Field(None, alias="projectId") + area: Optional[float] = Field(None, alias="projectArea(in sq.km)") + total_mappers: Optional[int] = Field(None, alias="totalMappers") + total_tasks: Optional[int] = Field(None, alias="totalTasks") + total_comments: Optional[int] = Field(None, alias="totalComments") + total_mapping_time: Optional[int] = Field(None, alias="totalMappingTime") + total_validation_time: Optional[int] = Field(None, alias="totalValidationTime") + total_time_spent: Optional[int] = Field(None, alias="totalTimeSpent") + average_mapping_time: Optional[int] = Field(None, alias="averageMappingTime") + average_validation_time: Optional[int] = Field(None, alias="averageValidationTime") + percent_mapped: Optional[int] = Field(None, alias="percentMapped") + percent_validated: Optional[int] = Field(None, alias="percentValidated") + percent_bad_imagery: Optional[int] = Field(None, alias="percentBadImagery") + aoi_centroid: Optional[str] = Field(None, alias="aoiCentroid") + time_to_finish_mapping: Optional[int] = Field(None, alias="timeToFinishMapping") + time_to_finish_validating: Optional[int] = Field( + None, alias="timeToFinishValidating" + ) + + class Config: + populate_by_name = True + + +class ProjectUserStatsDTO(BaseModel): """DTO for time spent by users on a project""" - time_spent_mapping = IntType(serialized_name="timeSpentMapping") - time_spent_validating = IntType(serialized_name="timeSpentValidating") - total_time_spent = IntType(serialized_name="totalTimeSpent") + time_spent_mapping: Optional[int] = Field(default=0, alias="timeSpentMapping") + time_spent_validating: Optional[int] = Field(default=0, alias="timeSpentValidating") + total_time_spent: Optional[int] = Field(default=0, alias="totalTimeSpent") + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 3b068be389..2210dd2910 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -1,7 +1,8 @@ -from schematics import Model -from schematics.types import LongType, UTCDateTimeType, StringType -from schematics.exceptions import ValidationError +from datetime import datetime from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError def is_known_action(value): @@ -20,45 +21,86 @@ def is_known_action(value): ) -class ProjectPartnershipDTO(Model): +# class ProjectPartnershipDTO(Model): +# """DTO for the link between a Partner and a Project""" + +# id = LongType(required=True) +# project_id = LongType(required=True, serialized_name="projectId") +# partner_id = LongType(required=True, serialized_name="partnerId") +# started_on = UTCDateTimeType(required=True, serialized_name="startedOn") +# ended_on = UTCDateTimeType(serialized_name="endedOn") + + +class ProjectPartnershipDTO(BaseModel): """DTO for the link between a Partner and a Project""" - id = LongType(required=True) - project_id = LongType(required=True, serialized_name="projectId") - partner_id = LongType(required=True, serialized_name="partnerId") - started_on = UTCDateTimeType(required=True, serialized_name="startedOn") - ended_on = UTCDateTimeType(serialized_name="endedOn") + id: Optional[int] = None + project_id: int = Field(..., alias="projectId") + partner_id: int = Field(..., alias="partnerId") + started_on: datetime = Field(..., alias="startedOn") + ended_on: Optional[datetime] = Field(None, alias="endedOn") + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class ProjectPartnershipUpdateDTO(Model): - """DTO for updating the time range of the link between a Partner and a Project""" - started_on = UTCDateTimeType(serialized_name="startedOn") - ended_on = UTCDateTimeType(serialized_name="endedOn") +# class ProjectPartnershipUpdateDTO(Model): +# """DTO for updating the time range of the link between a Partner and a Project""" +# started_on = UTCDateTimeType(serialized_name="startedOn") +# ended_on = UTCDateTimeType(serialized_name="endedOn") -class ProjectPartnershipHistoryDTO(Model): - """DTO for Logs of changes to all Project-Partner links""" - id = LongType(required=True) - partnership_id = LongType(required=True, serialized_name="partnershipId") - project_id = LongType(required=True, serialized_name="projectId") - partner_id = LongType(required=True, serialized_name="partnerId") - started_on_old = UTCDateTimeType( - serialized_name="startedOnOld", serialize_when_none=False - ) - ended_on_old = UTCDateTimeType( - serialized_name="endedOnOld", serialize_when_none=False - ) - started_on_new = UTCDateTimeType( - serialized_name="startedOnNew", serialize_when_none=False - ) - ended_on_new = UTCDateTimeType( - serialized_name="endedOnNew", serialize_when_none=False - ) +class ProjectPartnershipUpdateDTO(BaseModel): + """DTO for updating the time range of the link between a Partner and a Project""" + + started_on: Optional[datetime] = Field(None, alias="startedOn") + ended_on: Optional[datetime] = Field(None, alias="endedOn") + + class Config: + populate_by_name = True + + +# class ProjectPartnershipHistoryDTO(Model): +# """DTO for Logs of changes to all Project-Partner links""" + +# id = LongType(required=True) +# partnership_id = LongType(required=True, serialized_name="partnershipId") +# project_id = LongType(required=True, serialized_name="projectId") +# partner_id = LongType(required=True, serialized_name="partnerId") +# started_on_old = UTCDateTimeType( +# serialized_name="startedOnOld", serialize_when_none=False +# ) +# ended_on_old = UTCDateTimeType( +# serialized_name="endedOnOld", serialize_when_none=False +# ) +# started_on_new = UTCDateTimeType( +# serialized_name="startedOnNew", serialize_when_none=False +# ) +# ended_on_new = UTCDateTimeType( +# serialized_name="endedOnNew", serialize_when_none=False +# ) + + +# action = StringType(validators=[is_known_action]) +# actionDate = UTCDateTimeType(serialized_name="actionDate") +class ProjectPartnershipHistoryDTO(BaseModel): + """DTO for Logs of changes to all Project-Partner links""" - action = StringType(validators=[is_known_action]) - actionDate = UTCDateTimeType(serialized_name="actionDate") + id: int + partnership_id: int = Field(..., alias="partnershipId") + project_id: int = Field(..., alias="projectId") + partner_id: int = Field(..., alias="partnerId") + started_on_old: Optional[datetime] = Field(None, alias="startedOnOld") + ended_on_old: Optional[datetime] = Field(None, alias="endedOnOld") + started_on_new: Optional[datetime] = Field(None, alias="startedOnNew") + ended_on_new: Optional[datetime] = Field(None, alias="endedOnNew") + action: str + action_date: Optional[datetime] = Field(None, alias="actionDate") + + class Config: + populate_by_name = True class ProjectPartnerAction(Enum): diff --git a/backend/models/dtos/settings_dto.py b/backend/models/dtos/settings_dto.py index 78f58af433..8cf9437dd2 100644 --- a/backend/models/dtos/settings_dto.py +++ b/backend/models/dtos/settings_dto.py @@ -1,20 +1,22 @@ -from schematics import Model -from schematics.types import StringType -from schematics.types.compound import ListType, ModelType +from typing import List, Optional +from pydantic import BaseModel, Field -class SupportedLanguage(Model): + +class SupportedLanguage(BaseModel): """Model representing language that Tasking Manager supports""" - code = StringType() - language = StringType() + code: Optional[str] = None + language: Optional[str] = None -class SettingsDTO(Model): +class SettingsDTO(BaseModel): """DTO used to define available tags""" - mapper_level_intermediate = StringType(serialized_name="mapperLevelIntermediate") - mapper_level_advanced = StringType(serialized_name="mapperLevelAdvanced") - supported_languages = ListType( - ModelType(SupportedLanguage), serialized_name="supportedLanguages" + mapper_level_intermediate: Optional[int] = Field( + None, alias="mapperLevelIntermediate" + ) + mapper_level_advanced: Optional[int] = Field(None, alias="mapperLevelAdvanced") + supported_languages: Optional[List[SupportedLanguage]] = Field( + None, alias="supportedLanguages" ) diff --git a/backend/models/dtos/stats_dto.py b/backend/models/dtos/stats_dto.py index ae16880cab..0fe2b7e51b 100644 --- a/backend/models/dtos/stats_dto.py +++ b/backend/models/dtos/stats_dto.py @@ -1,129 +1,148 @@ -from schematics import Model -from schematics.types import StringType, IntType, FloatType, BooleanType, DateType -from schematics.types.compound import ListType, ModelType +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field + from backend.models.dtos.mapping_dto import TaskHistoryDTO, TaskStatusDTO -class UserContribution(Model): +class UserContribution(BaseModel): """User contribution for a project""" - username = StringType() - mapping_level = StringType(serialized_name="mappingLevel") - picture_url = StringType(serialized_name="pictureUrl") - mapped = IntType() - validated = IntType() - bad_imagery = IntType(serialized_name="badImagery") - total = IntType() - mapped_tasks = ListType(IntType, serialized_name="mappedTasks") - validated_tasks = ListType(IntType, serialized_name="validatedTasks") - bad_imagery_tasks = ListType(IntType, serialized_name="badImageryTasks") - name = StringType() - date_registered = DateType(serialized_name="dateRegistered") - - -class ProjectContributionsDTO(Model): + def __init__(self, UserContribution): + super().__init__() + self.username = UserContribution["username"] + self.mapping_level = UserContribution["mapping_level"] + self.picture_url = UserContribution["picture_url"] + self.mapped = UserContribution["mapped"] + self.validated = UserContribution["validated"] + self.bad_imagery = UserContribution["bad_imagery"] + self.total = UserContribution["total"] + self.mapped_tasks = UserContribution["mapped_tasks"] + self.validated_tasks = UserContribution["validated_tasks"] + self.bad_imagery_tasks = UserContribution["bad_imagery_tasks"] + self.name = UserContribution["name"] + self.date_registered = UserContribution["date_registered"] + + username: Optional[str] = None + mapping_level: Optional[str] = Field(alias="mappingLevel", default=None) + picture_url: Optional[str] = Field(alias="pictureUrl", default=None) + mapped: Optional[int] = None + validated: Optional[int] = None + bad_imagery: Optional[int] = Field(alias="badImagery", default=None) + total: Optional[int] = None + mapped_tasks: Optional[List[int]] = Field(alias="mappedTasks", default=None) + validated_tasks: Optional[List[int]] = Field(alias="validatedTasks", default=None) + bad_imagery_tasks: Optional[List[int]] = Field( + alias="badImageryTasks", default=None + ) + name: Optional[str] = None + date_registered: Optional[date] = Field(alias="dateRegistered", default=None) + + +class ProjectContributionsDTO(BaseModel): """DTO for all user contributions on a project""" def __init__(self): super().__init__() self.user_contributions = [] - user_contributions = ListType( - ModelType(UserContribution), serialized_name="userContributions" + user_contributions: Optional[List[UserContribution]] = Field( + alias="userContributions", default=None ) -class Pagination(Model): - """Properties for paginating results""" - - def __init__(self, paginated_result): - """Instantiate from a Flask-SQLAlchemy paginated result""" - super().__init__() - - self.has_next = paginated_result.has_next - self.has_prev = paginated_result.has_prev - self.next_num = paginated_result.next_num - self.page = paginated_result.page - self.pages = paginated_result.pages - self.prev_num = paginated_result.prev_num - self.per_page = paginated_result.per_page - self.total = paginated_result.total - - has_next = BooleanType(serialized_name="hasNext") - has_prev = BooleanType(serialized_name="hasPrev") - next_num = IntType(serialized_name="nextNum") - page = IntType() - pages = IntType() - prev_num = IntType(serialized_name="prevNum") - per_page = IntType(serialized_name="perPage") - total = IntType() - - -class ProjectActivityDTO(Model): +class Pagination(BaseModel): + has_next: Optional[bool] = Field(serialization_alias="hasNext", default=False) + has_prev: Optional[bool] = Field(serialization_alias="hasPrev", default=False) + next_num: Optional[int] = Field(serialization_alias="nextNum", default=None) + page: Optional[int] = None + pages: Optional[int] = None + prev_num: Optional[int] = Field(serialization_alias="prevNum", default=None) + per_page: Optional[int] = Field(serialization_alias="perPage", default=None) + total: Optional[int] = None + + @staticmethod + def from_total_count(page: int, per_page: int, total: int) -> "Pagination": + pages = (total + per_page - 1) // per_page + has_next = page < pages + has_prev = page > 1 + next_num = page + 1 if has_next else None + prev_num = page - 1 if has_prev else None + + return Pagination( + has_next=has_next, + has_prev=has_prev, + next_num=next_num, + page=page, + pages=pages, + prev_num=prev_num, + per_page=per_page, + total=total, + ) + + +class ProjectActivityDTO(BaseModel): """DTO to hold all project activity""" - def __init__(self): - super().__init__() - self.activity = [] - - pagination = ModelType(Pagination) - activity = ListType(ModelType(TaskHistoryDTO)) + pagination: Optional[Pagination] = None + activity: Optional[List[TaskHistoryDTO]] = None -class ProjectLastActivityDTO(Model): +class ProjectLastActivityDTO(BaseModel): """DTO to hold latest status from project activity""" - def __init__(self): - super().__init__() - self.activity = [] - - activity = ListType(ModelType(TaskStatusDTO)) + activity: Optional[List[TaskStatusDTO]] = Field(default_factory=list) -class OrganizationProjectsStatsDTO(Model): - draft = IntType() - published = IntType() - archived = IntType() - recent = IntType() # projects created in the current year - stale = IntType() # project without any activity in the last 6 months +class OrganizationProjectsStatsDTO(BaseModel): + draft: Optional[int] = None + published: Optional[int] = None + archived: Optional[int] = None + recent: Optional[int] = None + stale: Optional[int] = None -class OrganizationTasksStatsDTO(Model): - ready = IntType() - locked_for_mapping = IntType(serialized_name="lockedForMapping") - locked_for_validation = IntType(serialized_name="lockedForValidation") - mapped = IntType() - validated = IntType() - invalidated = IntType() - badimagery = IntType(serialized_name="badImagery") +class OrganizationTasksStatsDTO(BaseModel): + ready: Optional[int] = 0 + locked_for_mapping: Optional[int] = Field(0, serialization_alias="lockedForMapping") + locked_for_validation: Optional[int] = Field( + 0, serialization_alias="lockedForValidation" + ) + mapped: Optional[int] = 0 + validated: Optional[int] = 0 + invalidated: Optional[int] = 0 + badimagery: Optional[int] = Field(0, serialization_alias="badImagery") -class OrganizationStatsDTO(Model): - projects = ModelType(OrganizationProjectsStatsDTO) - active_tasks = ModelType(OrganizationTasksStatsDTO, serialized_name="activeTasks") +class OrganizationStatsDTO(BaseModel): + projects: Optional[OrganizationProjectsStatsDTO] = None + active_tasks: Optional[OrganizationTasksStatsDTO] = Field( + None, serialization_alias="activeTasks" + ) -class OrganizationListStatsDTO(Model): +class OrganizationListStatsDTO(BaseModel): def __init__(self, row): super().__init__() self.organisation = row[0] self.projects_created = row[1] - organisation = StringType() - projects_created = IntType(serialized_name="projectsCreated") + organisation: str + projects_created: int = Field(alias="projectsCreated") -class CampaignStatsDTO(Model): +class CampaignStatsDTO(BaseModel): def __init__(self, row): super().__init__() self.campaign = row[0] self.projects_created = row[1] - campaign = StringType() - projects_created = IntType(serialized_name="projectsCreated") + campaign: str + projects_created: int = Field(alias="projectsCreated") -class HomePageStatsDTO(Model): +class HomePageStatsDTO(BaseModel): """DTO for stats we want to display on the homepage""" def __init__(self): @@ -131,57 +150,62 @@ def __init__(self): self.organisations = [] self.campaigns = [] - mappers_online = IntType(serialized_name="mappersOnline") - total_area = IntType(serialized_name="totalArea") - tasks_mapped = IntType(serialized_name="tasksMapped") - tasks_validated = IntType(serialized_name="tasksValidated") - total_mappers = IntType(serialized_name="totalMappers") - total_validators = IntType(serialized_name="totalValidators") - total_projects = IntType(serialized_name="totalProjects") - total_mapped_area = FloatType(serialized_name="totalMappedArea") - total_validated_area = FloatType(serialized_name="totalValidatedArea") - total_organisations = IntType(serialized_name="totalOrganisations") - total_campaigns = IntType(serialized_name="totalCampaigns") - # avg_completion_time = IntType(serialized_name='averageCompletionTime') - organisations = ListType(ModelType(OrganizationListStatsDTO)) - campaigns = ListType(ModelType(CampaignStatsDTO)) - - -class TaskStats(Model): + mappers_online: Optional[int] = Field(None, alias="mappersOnline") + total_area: Optional[int] = Field(None, alias="totalArea") + tasks_mapped: Optional[int] = Field(None, alias="tasksMapped") + tasks_validated: Optional[int] = Field(None, alias="tasksValidated") + total_mappers: Optional[int] = Field(None, alias="totalMappers") + total_validators: Optional[int] = Field(None, alias="totalValidators") + total_projects: Optional[int] = Field(None, alias="totalProjects") + total_mapped_area: Optional[float] = Field(None, alias="totalMappedArea") + total_validated_area: Optional[float] = Field(None, alias="totalValidatedArea") + total_organisations: Optional[int] = Field(None, alias="totalOrganisations") + total_campaigns: Optional[int] = Field(None, alias="totalCampaigns") + avg_completion_time: Optional[int] = Field(None, alias="averageCompletionTime") + organisations: Optional[List[OrganizationListStatsDTO]] = None + campaigns: Optional[List[CampaignStatsDTO]] = None + + class Config: + populate_by_name = True + + +class TaskStats(BaseModel): """DTO for tasks stats for a single day""" - date = DateType(required=True) - mapped = IntType(serialized_name="mapped") - validated = IntType(serialized_name="validated") - bad_imagery = IntType(serialized_name="badImagery") + date: str + mapped: int = Field(alias="mapped") + validated: int = Field(alias="validated") + bad_imagery: int = Field(alias="badImagery") + + class Config: + populate_by_name = True -class GenderStatsDTO(Model): +class GenderStatsDTO(BaseModel): """DTO for genre stats of users.""" - male = IntType() - female = IntType() - prefer_not = IntType(serialized_name="preferNotIdentify") - self_describe = IntType(serialized_name="selfDescribe") + male: int = Field(None, alias="male") + female: int = Field(None, alias="female") + prefer_not: int = Field(None, alias="preferNotIdentify") + self_describe: int = Field(None, alias="selfDescribe") -class UserStatsDTO(Model): +class UserStatsDTO(BaseModel): """DTO for user stats.""" - total = IntType() - beginner = IntType() - intermediate = IntType() - advanced = IntType() - contributed = IntType() - email_verified = IntType(serialized_name="emailVerified") - genders = ModelType(GenderStatsDTO) + total: int = Field(None, alias="total") + beginner: int = Field(None, alias="beginner") + intermediate: int = Field(None, alias="intermediate") + advanced: int = Field(None, alias="advanced") + contributed: int = Field(None, alias="contributed") + email_verified: int = Field(None, alias="emailVerified") + genders: GenderStatsDTO = Field(None, alias="genders") -class TaskStatsDTO(Model): +class TaskStatsDTO(BaseModel): """Contains all tasks stats broken down by day""" - def __init__(self): - super().__init__() - self.stats = [] + stats: List[TaskStats] = Field([], alias="taskStats") - stats = ListType(ModelType(TaskStats), serialized_name="taskStats") + class Config: + populate_by_name = True diff --git a/backend/models/dtos/tags_dto.py b/backend/models/dtos/tags_dto.py index ba3dabd1c4..18ae214c0f 100644 --- a/backend/models/dtos/tags_dto.py +++ b/backend/models/dtos/tags_dto.py @@ -1,9 +1,9 @@ -from schematics import Model -from schematics.types import StringType -from schematics.types.compound import ListType +from typing import List, Optional +from pydantic import BaseModel -class TagsDTO(Model): + +class TagsDTO(BaseModel): """DTO used to define available tags""" - tags = ListType(StringType) + tags: Optional[List[str]] = None diff --git a/backend/models/dtos/task_annotation_dto.py b/backend/models/dtos/task_annotation_dto.py index 67f6cd3fd7..d84f971e3e 100644 --- a/backend/models/dtos/task_annotation_dto.py +++ b/backend/models/dtos/task_annotation_dto.py @@ -1,13 +1,16 @@ -from schematics import Model -from schematics.types import StringType, IntType -from schematics.types.compound import DictType +from typing import Optional +from pydantic import BaseModel, Field -class TaskAnnotationDTO(Model): + +class TaskAnnotationDTO(BaseModel): """Model for a single task annotation""" - task_id = IntType(required=True, serialized_name="taskId") - annotation_type = StringType(required=True, serialized_name="annotationType") - annotation_source = StringType(serialized_name="annotationSource") - annotation_markdown = StringType(serialized_name="annotationMarkdown") - properties = DictType(StringType, serialized_name="properties") + task_id: Optional[int] = Field(None, alias="taskId") + annotation_type: Optional[str] = Field(None, alias="annotationType") + annotation_source: Optional[str] = Field(None, alias="annotationSource") + annotation_markdown: Optional[str] = Field(None, alias="annotationMarkdown") + properties: Optional[dict] = Field(None, alias="properties") + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/team_dto.py b/backend/models/dtos/team_dto.py index 2c1347d2b4..210ff1f478 100644 --- a/backend/models/dtos/team_dto.py +++ b/backend/models/dtos/team_dto.py @@ -1,199 +1,249 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import ( - BooleanType, - IntType, - StringType, - LongType, - ListType, - ModelType, - UTCDateTimeType, -) +from datetime import datetime +from typing import List, Optional + +from fastapi import HTTPException +from pydantic import BaseModel, Field, field_validator from backend.models.dtos.stats_dto import Pagination from backend.models.postgis.statuses import ( + TeamJoinMethod, TeamMemberFunctions, TeamVisibility, - TeamJoinMethod, ) -def validate_team_visibility(value): - """Validates that value is a known Team Visibility""" +def validate_team_visibility(value: str) -> str: + """Validates that value is a known Team Visibility.""" try: TeamVisibility[value.upper()] except KeyError: - raise ValidationError( - f"Unknown teamVisibility: {value} Valid values are " - f"{TeamVisibility.PUBLIC.name}, " - f"{TeamVisibility.PRIVATE.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown teamVisibility: {value}. Valid values are: " + f"{TeamVisibility.PUBLIC.name}, " + f"{TeamVisibility.PRIVATE.name}." + ), ) + return value -def validate_team_join_method(value): - """Validates join method value and its visibility""" +def validate_team_join_method(value: str): + """Validates join method value and its visibility.""" try: TeamJoinMethod[value.upper()] except KeyError: - raise ValidationError( - f"Unknown teamJoinMethod: {value} Valid values are " - f"{TeamJoinMethod.ANY.name}, " - f"{TeamJoinMethod.BY_INVITE.name}, " - f"{TeamJoinMethod.BY_REQUEST.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown teamJoinMethod: {value}. " + f"Valid values are: {TeamJoinMethod.ANY.name}, " + f"{TeamJoinMethod.BY_INVITE.name}, " + f"{TeamJoinMethod.BY_REQUEST.name}." + ), ) + return value -def validate_team_member_function(value): - """Validates that value is a known Team Member Function""" +def validate_team_member_function(value: str): + """Validates that value is a known Team Member Function.""" try: TeamMemberFunctions[value.upper()] except KeyError: - raise ValidationError( - f"Unknown teamMemberFunction: {value} Valid values are " - f"{TeamMemberFunctions.MEMBER.name}, " - f"{TeamMemberFunctions.MANAGER.name}" + raise HTTPException( + status_code=400, + detail=( + f"Unknown teamMemberFunction: {value}. " + f"Valid values are: {TeamMemberFunctions.MEMBER.name}, " + f"{TeamMemberFunctions.MANAGER.name}." + ), ) + return value -class TeamMembersDTO(Model): - """Describe a JSON model for team members""" - - username = StringType(required=True) - function = StringType(required=True, validators=[validate_team_member_function]) - active = BooleanType() - join_request_notifications = BooleanType( - default=False, serialized_name="joinRequestNotifications" +class TeamMembersDTO(BaseModel): + username: str + function: str + active: bool + join_request_notifications: bool = Field( + default=False, alias="joinRequestNotifications" ) - picture_url = StringType(serialized_name="pictureUrl") - joined_date = UTCDateTimeType(serialized_name="joinedDate") + picture_url: Optional[str] = Field(None, alias="pictureUrl") + joined_date: Optional[datetime] = Field(None, alias="joinedDate") + @field_validator("function") + def validate_function(cls, value): + return validate_team_member_function(value) -class TeamProjectDTO(Model): + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} + + +class TeamProjectDTO(BaseModel): """Describes a JSON model to create a project team""" - project_name = StringType(required=True) - project_id = IntType(required=True) - role = StringType(required=True) + project_name: str = Field(None) + project_id: int = Field(None) + role: str = Field(None) -class ProjectTeamDTO(Model): +class ProjectTeamDTO(BaseModel): """Describes a JSON model to create a project team""" - team_id = IntType(required=True, serialized_name="teamId") - team_name = StringType(serialized_name="name") - role = StringType(required=True) + team_id: int = Field(alias="teamId") + team_name: str = Field(default=None, alias="name") + role: str = Field() + class Config: + populate_by_name = True + use_enum_values = True -class TeamDetailsDTO(Model): - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.members = [] - self.team_projects = [] - - """ Describes JSON model for a team """ - team_id = IntType(serialized_name="teamId") - organisation_id = IntType(required=True) - organisation = StringType(required=True) - organisation_slug = StringType(serialized_name="organisationSlug") - name = StringType(required=True) - logo = StringType() - description = StringType() - join_method = StringType( - required=True, - validators=[validate_team_join_method], - serialized_name="joinMethod", - ) - visibility = StringType( - required=True, validators=[validate_team_visibility], serialize_when_none=False - ) - is_org_admin = BooleanType(default=False) - is_general_admin = BooleanType(default=False) - members = ListType(ModelType(TeamMembersDTO)) - team_projects = ListType(ModelType(ProjectTeamDTO)) +class TeamDetailsDTO(BaseModel): + """Pydantic model equivalent of the original TeamDetailsDTO""" + + team_id: Optional[int] = Field(None, alias="teamId") + organisation_id: int + organisation: str + organisation_slug: Optional[str] = Field(None, alias="organisationSlug") + name: str + logo: Optional[str] = None + description: Optional[str] = None + join_method: str = Field(alias="joinMethod") + visibility: str + is_org_admin: bool = Field(False) + is_general_admin: bool = Field(False) + members: List[TeamMembersDTO] = Field([], alias="members") + team_projects: List[TeamProjectDTO] = Field([], alias="team_projects") + + @field_validator("join_method") + def validate_join_method(cls, value): + return validate_team_join_method(value) + + @field_validator("visibility") + def validate_visibility(cls, value): + return validate_team_visibility(value) -class TeamDTO(Model): + class Config: + populate_by_name = True + + +class TeamDTO(BaseModel): """Describes JSON model for a team""" - team_id = IntType(serialized_name="teamId") - organisation_id = IntType(required=True, serialized_name="organisationId") - organisation = StringType(required=True) - name = StringType(required=True) - logo = StringType() - description = StringType() - join_method = StringType( - required=True, - validators=[validate_team_join_method], - serialized_name="joinMethod", - ) - visibility = StringType( - required=True, validators=[validate_team_visibility], serialize_when_none=False - ) - members = ListType(ModelType(TeamMembersDTO)) - members_count = IntType(serialized_name="membersCount", required=False) - managers_count = IntType(serialized_name="managersCount", required=False) + team_id: Optional[int] = Field(None, alias="teamId") + organisation_id: int = Field(None, alias="organisationId") + organisation: str = Field(None, alias="organisation") + name: str = Field(None, alias="name") + logo: Optional[str] = None + description: Optional[str] = None + join_method: str = Field(None, alias="joinMethod") + visibility: str = Field(None, alias="visibility") + members: Optional[List[TeamMembersDTO]] = None + members_count: Optional[int] = Field(None, alias="membersCount") + managers_count: Optional[int] = Field(None, alias="managersCount") + + @field_validator("join_method") + def validate_join_method(cls, value): + return validate_team_join_method(value) + @field_validator("visibility") + def validate_visibility(cls, value): + return validate_team_visibility(value) -class TeamsListDTO(Model): + class Config: + populate_by_name = True + + +class TeamsListDTO(BaseModel): + def __init__(self): + """DTO constructor initialise all arrays to empty""" + super().__init__() + self.teams = [] + + """ Returns List of all teams""" + teams: List[TeamDTO] = [] + pagination: Optional[Pagination] = None + + +class ListTeamsDTO(BaseModel): def __init__(self): """DTO constructor initialise all arrays to empty""" super().__init__() self.teams = [] """ Returns List of all teams""" - teams = ListType(ModelType(TeamDTO)) - pagination = ModelType(Pagination) + teams: List[ProjectTeamDTO] = [] + pagination: Optional[Pagination] = None -class NewTeamDTO(Model): +class NewTeamDTO(BaseModel): """Describes a JSON model to create a new team""" - creator = LongType(required=True) - organisation_id = IntType(required=True) - name = StringType(required=True) - description = StringType() - join_method = StringType( - required=True, - validators=[validate_team_join_method], - serialized_name="joinMethod", - ) - visibility = StringType( - required=True, validators=[validate_team_visibility], serialize_when_none=False + creator: float = Field(None, alias="creator") + organisation_id: int = Field(..., alias="organisation_id") + name: str = Field(..., alias="name") + description: Optional[str] = Field(None, alias="description") + join_method: str = Field( + ..., + alias="joinMethod", ) + visibility: str = Field(..., serialize_when_none=False) + + @field_validator("join_method") + def validate_join_method(cls, value): + return validate_team_join_method(value) + + @field_validator("visibility") + def validate_visibility(cls, value): + return validate_team_visibility(value) + class Config: + populate_by_name = True -class UpdateTeamDTO(Model): + +class UpdateTeamDTO(BaseModel): """Describes a JSON model to update a team""" - creator = LongType() - team_id = IntType() - organisation = StringType() - organisation_id = IntType() - name = StringType() - logo = StringType() - description = StringType() - join_method = StringType( - validators=[validate_team_join_method], serialized_name="joinMethod" - ) - visibility = StringType( - validators=[validate_team_visibility], serialize_when_none=False - ) - members = ListType(ModelType(TeamMembersDTO), serialize_when_none=False) + creator: Optional[int] = Field(None, alias="creator") + team_id: Optional[int] = Field(None, alias="team_id") + organisation: Optional[str] = Field(None, alias="organisation") + organisation_id: Optional[int] = Field(None, alias="organisation_id") + name: Optional[str] = Field(None, alias="name") + logo: Optional[str] = Field(None, alias="logo") + description: Optional[str] = Field(None, alias="description") + join_method: Optional[str] = Field(None, alias="joinMethod") + visibility: Optional[str] = Field(None, serialize_when_none=False) + members: Optional[List[TeamMembersDTO]] = Field([], serialize_when_none=False) + + @field_validator("join_method") + def validate_join_method(cls, value): + return validate_team_join_method(value) + + @field_validator("visibility") + def validate_visibility(cls, value): + return validate_team_visibility(value) + + class Config: + populate_by_name = True -class TeamSearchDTO(Model): +class TeamSearchDTO(BaseModel): """Describes a JSON model to search for a team""" - user_id = LongType(serialized_name="userId") - organisation = IntType(serialized_name="organisation") - team_name = StringType(serialized_name="team_name") - omit_members = BooleanType(serialized_name="omitMemberList", default=False) - full_members_list = BooleanType(serialized_name="fullMemberList", default=True) - member = LongType(serialized_name="member") - manager = LongType(serialized_name="manager") - team_role = StringType(serialized_name="team_role") - member_request = LongType(serialized_name="member_request") - paginate = BooleanType(serialized_name="paginate", default=False) - page = IntType(serialized_name="page", default=1) - per_page = IntType(serialized_name="perPage", default=10) + user_id: Optional[int] = Field(None, alias="userId") + organisation: Optional[int] = Field(None, alias="organisation") + team_name: Optional[str] = Field(None, alias="team_name") + omit_members: Optional[bool] = Field(False, alias="omitMemberList") + full_members_list: Optional[bool] = Field(True, alias="fullMemberList") + member: Optional[int] = Field(None, alias="member") + manager: Optional[int] = Field(None, alias="manager") + team_role: Optional[str] = Field(None, alias="team_role") + member_request: Optional[int] = Field(None, alias="member_request") + paginate: Optional[bool] = Field(False, alias="paginate") + page: Optional[int] = Field(1, alias="page") + per_page: Optional[int] = Field(10, alias="perPage") + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/user_dto.py b/backend/models/dtos/user_dto.py index 2308ceff8c..4943eda297 100644 --- a/backend/models/dtos/user_dto.py +++ b/backend/models/dtos/user_dto.py @@ -1,33 +1,13 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import ( - StringType, - IntType, - EmailType, - LongType, - BooleanType, -) -from schematics.types.compound import ListType, ModelType, BaseType -from backend.models.dtos.stats_dto import Pagination -from backend.models.dtos.mapping_dto import TaskDTO -from backend.models.dtos.interests_dto import InterestDTO -from backend.models.postgis.statuses import MappingLevel, UserRole - +from datetime import datetime +from typing import Dict, List, Optional -def is_known_mapping_level(value): - """Validates that supplied mapping level is known value""" - if value.upper() == "ALL": - return True +from pydantic import BaseModel, Field +from pydantic.functional_validators import field_validator - try: - value = value.split(",") - for level in value: - MappingLevel[level.upper()] - except KeyError: - raise ValidationError( - f"Unknown mappingLevel: {value} Valid values are {MappingLevel.BEGINNER.name}, " - f"{MappingLevel.INTERMEDIATE.name}, {MappingLevel.ADVANCED.name}, ALL" - ) +from backend.models.dtos.interests_dto import InterestDTO +from backend.models.dtos.mapping_dto import TaskDTO +from backend.models.dtos.stats_dto import Pagination +from backend.models.postgis.statuses import MappingLevel, UserRole def is_known_role(value): @@ -37,60 +17,70 @@ def is_known_role(value): for role in value: UserRole[role.upper()] except KeyError: - raise ValidationError( + raise ValueError( f"Unknown mappingRole: {value} Valid values are {UserRole.ADMIN.name}, " f"{UserRole.READ_ONLY.name}, {UserRole.MAPPER.name}" ) -class UserDTO(Model): +class UserDTO(BaseModel): """DTO for User""" - id = LongType() - username = StringType() - role = StringType() - mapping_level = StringType( - serialized_name="mappingLevel", validators=[is_known_mapping_level] + id: Optional[int] = None + username: Optional[str] = None + role: Optional[str] = None + mapping_level: Optional[str] = Field(None, alias="mappingLevel") + projects_mapped: Optional[int] = Field(None, alias="projectsMapped") + email_address: Optional[str] = Field(None, alias="emailAddress") + is_email_verified: Optional[bool] = Field( + None, alias="isEmailVerified", serialize_when_none=False ) - projects_mapped = IntType(serialized_name="projectsMapped") - email_address = EmailType(serialized_name="emailAddress") - - is_email_verified = EmailType( - serialized_name="isEmailVerified", serialize_when_none=False - ) - is_expert = BooleanType(serialized_name="isExpert", serialize_when_none=False) - twitter_id = StringType(serialized_name="twitterId") - facebook_id = StringType(serialized_name="facebookId") - linkedin_id = StringType(serialized_name="linkedinId") - slack_id = StringType(serialized_name="slackId") - irc_id = StringType(serialized_name="ircId") - skype_id = StringType(serialized_name="skypeId") - city = StringType(serialized_name="city") - country = StringType(serialized_name="country") - name = StringType(serialized_name="name") - picture_url = StringType(serialized_name="pictureUrl") - default_editor = StringType(serialized_name="defaultEditor") - mentions_notifications = BooleanType(serialized_name="mentionsNotifications") - projects_comments_notifications = BooleanType( - serialized_name="questionsAndCommentsNotifications" + is_expert: bool = Field(None, alias="isExpert", serialize_when_none=False) + twitter_id: Optional[str] = Field(None, alias="twitterId") + facebook_id: Optional[str] = Field(None, alias="facebookId") + linkedin_id: Optional[str] = Field(None, alias="linkedinId") + slack_id: Optional[str] = Field(None, alias="slackId") + irc_id: Optional[str] = Field(None, alias="ircId") + skype_id: Optional[str] = Field(None, alias="skypeId") + city: Optional[str] = Field(None, alias="city") + country: Optional[str] = Field(None, alias="country") + name: Optional[str] = Field(None, alias="name") + picture_url: Optional[str] = Field(None, alias="pictureUrl") + default_editor: Optional[str] = Field(None, alias="defaultEditor") + mentions_notifications: bool = Field(None, alias="mentionsNotifications") + projects_comments_notifications: bool = Field( + None, alias="questionsAndCommentsNotifications" ) - projects_notifications = BooleanType(serialized_name="projectsNotifications") - tasks_notifications = BooleanType(serialized_name="tasksNotifications") - tasks_comments_notifications = BooleanType( - serialized_name="taskCommentsNotifications" - ) - teams_announcement_notifications = BooleanType( - serialized_name="teamsAnnouncementNotifications" + projects_notifications: bool = Field(None, alias="projectsNotifications") + tasks_notifications: bool = Field(None, alias="tasksNotifications") + tasks_comments_notifications: bool = Field(None, alias="taskCommentsNotifications") + teams_announcement_notifications: bool = Field( + None, alias="teamsAnnouncementNotifications" ) # these are read only - gender = StringType( - serialized_name="gender", + gender: Optional[str] = Field( + None, + alias="gender", choices=("MALE", "FEMALE", "SELF_DESCRIBE", "PREFER_NOT"), ) - self_description_gender = StringType( - serialized_name="selfDescriptionGender", default=None - ) + self_description_gender: Optional[str] = Field(None, alias="selfDescriptionGender") + + @field_validator("mapping_level", mode="before") + def is_known_mapping_level(value): + """Validates that supplied mapping level is known value""" + if value.upper() == "ALL": + return True + + try: + value = value.split(",") + for level in value: + MappingLevel[level.upper()] + except KeyError: + raise ValueError( + f"Unknown mappingLevel: {value} Valid values are {MappingLevel.BEGINNER.name}, " + f"{MappingLevel.INTERMEDIATE.name}, {MappingLevel.ADVANCED.name}, ALL" + ) def validate_self_description(self, data, value): if ( @@ -101,142 +91,157 @@ def validate_self_description(self, data, value): return value -class UserCountryContributed(Model): +class UserCountryContributed(BaseModel): """DTO for country a user has contributed""" - name = StringType(required=True) - mapped = IntType(required=True) - validated = IntType(required=True) - total = IntType(required=True) + name: str = Field(None) + mapped: int = Field(None, alias="mapped") + validated: int = Field(None, alias="validated") + total: int = Field(None) -class UserCountriesContributed(Model): +class UserCountriesContributed(BaseModel): """DTO for countries a user has contributed""" - def __init__(self): - super().__init__() - self.countries_contributed = [] + countries_contributed: List[UserCountryContributed] = Field([], alias="countries") + total: int = Field(None) - countries_contributed = ListType( - ModelType(UserCountryContributed), serialized_name="countries" - ) - total = IntType() + class Config: + populate_by_name = True -class UserContributionDTO(Model): - date = StringType() - count = IntType() +class UserContributionDTO(BaseModel): + date: datetime + count: int -class UserStatsDTO(Model): +class UserStatsDTO(BaseModel): """DTO containing statistics about the user""" - total_time_spent = IntType(serialized_name="totalTimeSpent") - time_spent_mapping = IntType(serialized_name="timeSpentMapping") - time_spent_validating = IntType(serialized_name="timeSpentValidating") - projects_mapped = IntType(serialized_name="projectsMapped") - countries_contributed = ModelType( - UserCountriesContributed, serialized_name="countriesContributed" + total_time_spent: int = Field(None, alias="totalTimeSpent") + time_spent_mapping: int = Field(None, alias="timeSpentMapping") + time_spent_validating: int = Field(None, alias="timeSpentValidating") + projects_mapped: int = Field(None, alias="projectsMapped") + countries_contributed: UserCountriesContributed = Field( + None, alias="countriesContributed" ) - contributions_by_day = ListType( - ModelType(UserContributionDTO), serialized_name="contributionsByDay" + contributions_by_day: List[UserContributionDTO] = Field( + [], alias="contributionsByDay" ) - tasks_mapped = IntType(serialized_name="tasksMapped") - tasks_validated = IntType(serialized_name="tasksValidated") - tasks_invalidated = IntType(serialized_name="tasksInvalidated") - tasks_invalidated_by_others = IntType(serialized_name="tasksInvalidatedByOthers") - tasks_validated_by_others = IntType(serialized_name="tasksValidatedByOthers") - contributions_interest = ListType( - ModelType(InterestDTO), serialized_name="ContributionsByInterest" + tasks_mapped: int = Field(None, alias="tasksMapped") + tasks_validated: int = Field(None, alias="tasksValidated") + tasks_invalidated: int = Field(None, alias="tasksInvalidated") + tasks_invalidated_by_others: int = Field(None, alias="tasksInvalidatedByOthers") + tasks_validated_by_others: int = Field(None, alias="tasksValidatedByOthers") + contributions_interest: List[InterestDTO] = Field( + [], alias="ContributionsByInterest" ) -class UserOSMDTO(Model): +class UserOSMDTO(BaseModel): """DTO containing OSM details for the user""" - account_created = StringType(required=True, serialized_name="accountCreated") - changeset_count = IntType(required=True, serialized_name="changesetCount") + account_created: Optional[str] = Field(None, alias="accountCreated") + changeset_count: Optional[int] = Field(None, alias="changesetCount") -class MappedProject(Model): +class MappedProject(BaseModel): """Describes a single project a user has mapped""" - project_id = IntType(serialized_name="projectId") - name = StringType() - tasks_mapped = IntType(serialized_name="tasksMapped") - tasks_validated = IntType(serialized_name="tasksValidated") - status = StringType() - centroid = BaseType() + project_id: Optional[int] = Field(None, alias="projectId") + name: Optional[str] = None + tasks_mapped: Optional[int] = Field(None, alias="tasksMapped") + tasks_validated: Optional[int] = Field(None, alias="tasksValidated") + status: Optional[str] = None + centroid: Optional[Dict] = None + class Config: + populate_by_name = True -class UserMappedProjectsDTO(Model): - """DTO for projects a user has mapped""" - def __init__(self): - super().__init__() - self.mapped_projects = [] +class UserMappedProjectsDTO(BaseModel): + """DTO for projects a user has mapped""" - mapped_projects = ListType( - ModelType(MappedProject), serialized_name="mappedProjects" + mapped_projects: Optional[List[MappedProject]] = Field( + default_factory=list, alias="mappedProjects" ) + class Config: + populate_by_name = True + -class UserSearchQuery(Model): +class UserSearchQuery(BaseModel): """Describes a user search query, that a user may submit to filter the list of users""" - username = StringType() - role = StringType(validators=[is_known_role]) - mapping_level = StringType( - serialized_name="mappingLevel", validators=[is_known_mapping_level] - ) - page = IntType() - pagination = BooleanType(default=True) - per_page = IntType(default=20, serialized_name="perPage") + username: Optional[str] = None + role: Optional[str] = Field(None) + mapping_level: Optional[str] = Field(None, alias="mappingLevel") + page: Optional[int] = None + pagination: bool = True + per_page: Optional[int] = Field(default=20, alias="perPage") + + class Config: + populate_by_name = True + + @field_validator("username", mode="before") + def validate_username(cls, v): + if v is None: + return None + return v.strip() + + @field_validator("role", mode="before") + def validate_role(cls, v): + if v is None: + return None + return v.strip() def __hash__(self): """Make object hashable so we can cache user searches""" return hash((self.username, self.role, self.mapping_level, self.page)) -class ListedUser(Model): +class ListedUser(BaseModel): """Describes a user within the User List""" - id = LongType() - username = StringType() - role = StringType() - mapping_level = StringType(serialized_name="mappingLevel") - picture_url = StringType(serialized_name="pictureUrl") + id: Optional[float] = None + username: Optional[str] = None + role: Optional[str] = None + mapping_level: Optional[str] = Field(None, alias="mappingLevel") + picture_url: Optional[str] = Field(None, alias="pictureUrl") -class UserRegisterEmailDTO(Model): +class UserRegisterEmailDTO(BaseModel): """DTO containing data for user registration with email model""" - id = IntType(serialize_when_none=False) - email = StringType(required=True) - success = BooleanType(default=False) - details = StringType() + id: int = Field(None, serialize_when_none=False) + email: str + success: bool = False + details: str = None -class ProjectParticipantUser(Model): +class ProjectParticipantUser(BaseModel): """Describes a user who has participated in a project""" - username = StringType() - project_id = LongType(serialized_name="projectId") - is_participant = BooleanType(serialized_name="isParticipant") + username: str + project_id: float = Field(alias="projectId") + is_participant: bool = Field(alias="isParticipant") + class Config: + populate_by_name = True -class UserSearchDTO(Model): + +class UserSearchDTO(BaseModel): """Paginated list of TM users""" def __init__(self): super().__init__() self.users = [] - pagination = ModelType(Pagination) - users = ListType(ModelType(ListedUser)) + pagination: Optional[Pagination] = None + users: Optional[List[ListedUser]] = None -class UserFilterDTO(Model): +class UserFilterDTO(BaseModel): """DTO to hold all Tasking Manager users""" def __init__(self): @@ -244,12 +249,12 @@ def __init__(self): self.usernames = [] self.users = [] - pagination = ModelType(Pagination) - usernames = ListType(StringType) - users = ListType(ModelType(ProjectParticipantUser)) + pagination: Optional[Pagination] = None + usernames: Optional[List[str]] = None + users: Optional[List[ProjectParticipantUser]] = None -class UserTaskDTOs(Model): +class UserTaskDTOs(BaseModel): """Describes an array of Task DTOs""" def __init__(self): @@ -257,5 +262,11 @@ def __init__(self): super().__init__() self.user_tasks = [] - user_tasks = ListType(ModelType(TaskDTO), serialized_name="tasks") - pagination = ModelType(Pagination) + user_tasks: List[TaskDTO] = Field([], alias="tasks") + pagination: Pagination = Field(None, alias="pagination") + + +class AuthUserDTO(BaseModel): + """A minimal user model with only id.""" + + id: int diff --git a/backend/models/dtos/validator_dto.py b/backend/models/dtos/validator_dto.py index f3a5d72bfc..3ae32ca56e 100644 --- a/backend/models/dtos/validator_dto.py +++ b/backend/models/dtos/validator_dto.py @@ -1,12 +1,13 @@ -from schematics import Model -from schematics.exceptions import ValidationError -from schematics.types import StringType, IntType, BooleanType, UTCDateTimeType -from schematics.types.compound import ListType, ModelType -from backend.models.postgis.statuses import TaskStatus +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field, ValidationError, field_validator + from backend.models.dtos.stats_dto import Pagination +from backend.models.postgis.statuses import TaskStatus -class ExtendedStringType(StringType): +class ExtendedStringType(str): converters = [] def __init__(self, **kwargs): @@ -66,121 +67,166 @@ def is_valid_revert_status(value): raise ValidationError(f"Invalid status. Valid values are {valid_values}") -class LockForValidationDTO(Model): +class LockForValidationDTO(BaseModel): """DTO used to lock multiple tasks for validation""" - project_id = IntType(required=True) - task_ids = ListType(IntType, required=True, serialized_name="taskIds") - user_id = IntType(required=True) - preferred_locale = StringType(default="en") + project_id: int + task_ids: List[int] = Field(None, alias="taskIds") + user_id: int + preferred_locale: str = "en" + class Config: + populate_by_name = True -class ValidationMappingIssue(Model): + +class ValidationMappingIssue(BaseModel): """Describes one or more occurrences of an identified mapping problem during validation""" - mapping_issue_category_id = IntType( - required=True, serialized_name="mappingIssueCategoryId" - ) - issue = StringType(required=True) - count = IntType(required=True) + mapping_issue_category_id: int = Field(None, alias="mappingIssueCategoryId") + issue: str + count: int + class Config: + populate_by_name = True -class ValidatedTask(Model): + +class ValidatedTask(BaseModel): """Describes the model used to update the status of one task after validation""" - task_id = IntType(required=True, serialized_name="taskId") - status = StringType(required=True, validators=[is_valid_validated_status]) - comment = StringType() - issues = ListType( - ModelType(ValidationMappingIssue), serialized_name="validationIssues" + task_id: int = Field(None, alias="taskId") + status: str = Field(None, validators=[is_valid_validated_status]) + comment: Optional[str] = None + issues: Optional[List[ValidationMappingIssue]] = Field( + None, alias="validationIssues" ) + class Config: + populate_by_name = True + -class ResetValidatingTask(Model): - """Describes the model used to stop validating and reset the status of one task""" +class ResetValidatingTask(BaseModel): + """Model used to stop validating and reset the status of one task""" - task_id = IntType(required=True, serialized_name="taskId") - comment = StringType() - issues = ListType( - ModelType(ValidationMappingIssue), serialized_name="validationIssues" + task_id: int = Field(alias="taskId") + comment: Optional[str] = None + issues: Optional[List[ValidationMappingIssue]] = Field( + None, alias="validationIssues" ) + class Config: + populate_by_name = True -class UnlockAfterValidationDTO(Model): + +class UnlockAfterValidationDTO(BaseModel): """DTO used to transmit the status of multiple tasks after validation""" - project_id = IntType(required=True) - validated_tasks = ListType( - ModelType(ValidatedTask), required=True, serialized_name="validatedTasks" - ) - user_id = IntType(required=True) - preferred_locale = StringType(default="en") + project_id: int + validated_tasks: List[ValidatedTask] = Field(None, alias="validatedTasks") + user_id: int + preferred_locale: str = Field(default="en") + class Config: + populate_by_name = True -class StopValidationDTO(Model): + +class StopValidationDTO(BaseModel): """DTO used to transmit the the request to stop validating multiple tasks""" - project_id = IntType(required=True) - reset_tasks = ListType( - ModelType(ResetValidatingTask), required=True, serialized_name="resetTasks" - ) - user_id = IntType(required=True) - preferred_locale = StringType(default="en") + project_id: int + reset_tasks: List[ResetValidatingTask] = Field(None, alias="resetTasks") + user_id: int + preferred_locale: str = Field(default="en") + class Config: + populate_by_name = True -class MappedTasksByUser(Model): + +class MappedTasksByUser(BaseModel): """Describes number of tasks user has mapped on a project""" - username = StringType(required=True) - mapped_task_count = IntType(required=True, serialized_name="mappedTaskCount") - tasks_mapped = ListType(IntType, required=True, serialized_name="tasksMapped") - last_seen = UTCDateTimeType(required=True, serialized_name="lastSeen") - mapping_level = StringType(required=True, serialized_name="mappingLevel") - date_registered = UTCDateTimeType(serialized_name="dateRegistered") - last_validation_date = UTCDateTimeType(serialized_name="lastValidationDate") + username: Optional[str] = None + mapped_task_count: Optional[int] = Field(default=None, alias="mappedTaskCount") + tasks_mapped: Optional[List[int]] = Field(default_factory=list, alias="tasksMapped") + last_seen: Optional[datetime] = Field(default=None, alias="lastSeen") + mapping_level: Optional[str] = Field(default=None, alias="mappingLevel") + date_registered: datetime = Field(alias="dateRegistered") + last_validation_date: Optional[datetime] = Field( + default=None, alias="lastValidationDate" + ) + + class Config: + populate_by_name = True -class InvalidatedTask(Model): +class InvalidatedTask(BaseModel): """Describes invalidated tasks with which user is involved""" - task_id = IntType(required=True, serialized_name="taskId") - project_id = IntType(required=True, serialized_name="projectId") - project_name = StringType(serialized_name="projectName") - history_id = IntType(serialized_name="historyId") - closed = BooleanType() - updated_date = UTCDateTimeType(serialized_name="updatedDate") + task_id: int = Field(None, alias="taskId") + project_id: int = Field(None, alias="projectId") + project_name: str = Field(alias="projectName") + history_id: int = Field(alias="historyId") + closed: bool + updated_date: datetime = Field(alias="updatedDate") + class Config: + populate_by_name = True -class InvalidatedTasks(Model): + +class InvalidatedTasks(BaseModel): def __init__(self): """DTO constructor initialise all arrays to empty""" super().__init__() self.invalidated_tasks = [] - invalidated_tasks = ListType( - ModelType(InvalidatedTask), serialized_name="invalidatedTasks" - ) - pagination = ModelType(Pagination) + invalidated_tasks: List[InvalidatedTask] = Field(alias="invalidatedTasks") + pagination: Pagination + class Config: + populate_by_name = True -class MappedTasks(Model): + +class MappedTasks(BaseModel): """Describes all tasks currently mapped on a project""" - def __init__(self): - """DTO constructor initialise all arrays to empty""" - super().__init__() - self.mapped_tasks = [] + mapped_tasks: List[MappedTasksByUser] = Field( + default_factory=list, alias="mappedTasks" + ) - mapped_tasks = ListType(ModelType(MappedTasksByUser), serialized_name="mappedTasks") + class Config: + populate_by_name = True -class RevertUserTasksDTO(Model): +class RevertUserTasksDTO(BaseModel): """DTO used to revert all tasks to a given status""" - preferred_locale = StringType(default="en") - project_id = IntType(required=True) - user_id = IntType(required=True) - action_by = IntType(required=True) - action = ExtendedStringType( - required=True, validators=[is_valid_revert_status], converters=[str.upper] - ) + preferred_locale: str = "en" + project_id: int + user_id: int + action_by: int + action: str + + class Config: + populate_by_name = True + + @field_validator("action", mode="before") + @classmethod + def validate_action(cls, value: str) -> str: + """Validates that Task Status is in the correct range for reverting""" + valid_values = f"{TaskStatus.BADIMAGERY.name}, {TaskStatus.VALIDATED.name}" + + if not isinstance(value, str): + raise ValidationError("Action must be a string.") + + value = value.upper() + + try: + validated_status = TaskStatus[value] + except KeyError: + raise ValidationError( + f"Unknown task status. Valid values are {valid_values}" + ) + + if validated_status not in [TaskStatus.VALIDATED, TaskStatus.BADIMAGERY]: + raise ValidationError(f"Invalid status. Valid values are {valid_values}") + + return value # Convert to uppercase, similar to converters=[str.upper] diff --git a/backend/models/postgis/application.py b/backend/models/postgis/application.py index 75fb8c913d..6922354bd1 100644 --- a/backend/models/postgis/application.py +++ b/backend/models/postgis/application.py @@ -1,20 +1,30 @@ -from backend import db +from databases import Database +from sqlalchemy import ( + BigInteger, + Column, + DateTime, + ForeignKey, + String, + delete, + insert, + select, +) + +from backend.db import Base from backend.models.dtos.application_dto import ApplicationDTO, ApplicationsDTO from backend.models.postgis.utils import timestamp from backend.services.users.authentication_service import AuthenticationService -class Application(db.Model): +class Application(Base): """Describes an application that is authorized to access the TM""" __tablename__ = "application_keys" - id = db.Column(db.BigInteger, primary_key=True) - user = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users"), nullable=False - ) - app_key = db.Column(db.String, nullable=False) - created = db.Column(db.DateTime, default=timestamp) + id = Column(BigInteger, primary_key=True) + user = Column(BigInteger, ForeignKey("users.id", name="fk_users"), nullable=False) + app_key = Column(String, nullable=False) + created = Column(DateTime, default=timestamp) def generate_application_key(self, user_id): """ @@ -23,33 +33,31 @@ def generate_application_key(self, user_id): token = AuthenticationService.generate_session_token_for_user(user_id) return token - def create(self, user_id): + async def create(self, user_id, db: Database): application = Application() application.app_key = self.generate_application_key(user_id) application.user = user_id - db.session.add(application) - db.session.commit() - + query = insert(Application.__table__).values( + app_key=application.app_key, user=application.user + ) + await db.execute(query) return application - def save(self): - db.session.commit() - - def delete(self): - db.session.delete(self) - db.session.commit() + async def delete(self, db: Database): + query = delete(Application).where(Application.id == self.id) + await db.execute(query) @staticmethod - def get_token(appkey: str): - return ( - db.session.query(Application) - .filter(Application.app_key == appkey) - .one_or_none() - ) + async def get_token(appkey: str, db: Database): + query = select(Application).where(Application.app_key == appkey) + result = await db.fetch_one(query) + return result @staticmethod - def get_all_for_user(user: int): - query = db.session.query(Application).filter(Application.user == user) + async def get_all_for_user(user: int, db: Database): + # query = session.query(Application).filter(Application.user == user) + query = select(Application).where(Application.user == user) + query = await db.fetch_all(query=query) applications_dto = ApplicationsDTO() for r in query: application_dto = ApplicationDTO() diff --git a/backend/models/postgis/banner.py b/backend/models/postgis/banner.py index 297deb4ec1..16190eb0c8 100644 --- a/backend/models/postgis/banner.py +++ b/backend/models/postgis/banner.py @@ -1,34 +1,40 @@ import bleach +from databases import Database from markdown import markdown +from sqlalchemy import Boolean, Column, Integer, String, insert, update -from backend import db +from backend.db import Base from backend.models.dtos.banner_dto import BannerDTO -class Banner(db.Model): +class Banner(Base): """Model for Banners""" __tablename__ = "banner" # Columns - id = db.Column(db.Integer, primary_key=True) - message = db.Column(db.String(255), nullable=False) - visible = db.Column(db.Boolean, default=False, nullable=False) + id = Column(Integer, primary_key=True) + message = Column(String(255), nullable=False) + visible = Column(Boolean, default=False, nullable=False) - def create(self): + async def create(self, db: Database): """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def update(self): - """Updates the current model in the DB""" - db.session.commit() + query = insert(Banner.__table__).values( + message=self.message, visible=self.visible + ) + await db.execute(query) - def update_from_dto(self, dto: BannerDTO): + async def update_from_dto(self, db: Database, dto: BannerDTO): """Updates the current model in the DB""" self.message = dto.message self.visible = dto.visible - db.session.commit() + query = ( + update(Banner.__table__) + .where(Banner.id == self.id) + .values(message=self.message, visible=self.visible) + ) + await db.execute(query) + return self def as_dto(self): """Returns a dto for the banner""" @@ -38,14 +44,15 @@ def as_dto(self): return banner_dto @staticmethod - def get(): + async def get(db: Database): """Returns a banner and creates one if it doesn't exist""" - banner = Banner.query.first() + query = """SELECT * FROM banner LIMIT 1""" + banner = await db.fetch_one(query=query) if banner is None: banner = Banner() banner.message = "Welcome to the API" banner.visible = True - banner.create() + await banner.create(db) return banner @staticmethod diff --git a/backend/models/postgis/campaign.py b/backend/models/postgis/campaign.py index 9e734d7e88..9992e37a25 100644 --- a/backend/models/postgis/campaign.py +++ b/backend/models/postgis/campaign.py @@ -1,56 +1,36 @@ -from backend import db -from backend.models.dtos.campaign_dto import CampaignDTO, CampaignListDTO +from sqlalchemy import Column, ForeignKey, Integer, String, Table, UniqueConstraint +from backend.db import Base +from backend.models.dtos.campaign_dto import CampaignDTO, CampaignListDTO -campaign_projects = db.Table( +campaign_projects = Table( "campaign_projects", - db.metadata, - db.Column("campaign_id", db.Integer, db.ForeignKey("campaigns.id")), - db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), + Base.metadata, + Column("campaign_id", Integer, ForeignKey("campaigns.id")), + Column("project_id", Integer, ForeignKey("projects.id")), ) -campaign_organisations = db.Table( +campaign_organisations = Table( "campaign_organisations", - db.metadata, - db.Column("campaign_id", db.Integer, db.ForeignKey("campaigns.id")), - db.Column("organisation_id", db.Integer, db.ForeignKey("organisations.id")), - db.UniqueConstraint( + Base.metadata, + Column("campaign_id", Integer, ForeignKey("campaigns.id")), + Column("organisation_id", Integer, ForeignKey("organisations.id")), + UniqueConstraint( "campaign_id", "organisation_id", name="campaign_organisation_key" ), ) -class Campaign(db.Model): +class Campaign(Base): """Describes an Campaign""" __tablename__ = "campaigns" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, nullable=False, unique=True) - logo = db.Column(db.String) - url = db.Column(db.String) - description = db.Column(db.String) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - - def save(self): - db.session.commit() - - def update(self, dto: CampaignDTO): - """Update the user details""" - self.name = dto.name if dto.name else self.name - self.logo = dto.logo if dto.logo else self.logo - self.url = dto.url if dto.url else self.url - self.description = dto.description if dto.description else self.description - db.session.commit() + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False, unique=True) + logo = Column(String) + url = Column(String) + description = Column(String) @classmethod def from_dto(cls, dto: CampaignDTO): @@ -79,10 +59,6 @@ def campaign_list_as_dto(campaigns: list) -> CampaignListDTO: """Converts a collection of campaigns into DTO""" campaign_list_dto = CampaignListDTO() for campaign in campaigns: - campaign_dto = CampaignDTO() - campaign_dto.id = campaign.id - campaign_dto.name = campaign.name - + campaign_dto = CampaignDTO(**campaign) campaign_list_dto.campaigns.append(campaign_dto) - return campaign_list_dto diff --git a/backend/models/postgis/custom_editors.py b/backend/models/postgis/custom_editors.py index 5c74cfb4dd..d5d07f2e15 100644 --- a/backend/models/postgis/custom_editors.py +++ b/backend/models/postgis/custom_editors.py @@ -1,49 +1,75 @@ -from backend import db +from databases import Database +from sqlalchemy import Column, ForeignKey, Integer, String + +from backend.db import Base from backend.models.dtos.project_dto import CustomEditorDTO -class CustomEditor(db.Model): +class CustomEditor(Base): """Model for user defined editors for a project""" __tablename__ = "project_custom_editors" - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) - name = db.Column(db.String(50), nullable=False) - description = db.Column(db.String) - url = db.Column(db.String, nullable=False) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() + project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) + name = Column(String(50), nullable=False) + description = Column(String) + url = Column(String, nullable=False) - def save(self): - """Save changes to db""" - db.session.commit() + async def get_by_project_id(project_id: int, db: Database): + """Retrieves a CustomEditor by project_id""" + query = """ + SELECT * FROM project_custom_editors + WHERE project_id = :project_id + """ + values = {"project_id": project_id} + row = await db.fetch_one(query, values=values) + if row: + return CustomEditor(**row) + else: + return None @staticmethod - def get_by_project_id(project_id: int): - """Get custom editor by it's project id""" - return db.session.get(CustomEditor, project_id) - - @classmethod - def create_from_dto(cls, project_id: int, dto: CustomEditorDTO): + async def create_from_dto(project_id: int, dto: CustomEditorDTO, db: Database): """Creates a new CustomEditor from dto, used in project edit""" - new_editor = cls() - new_editor.project_id = project_id - new_editor.update_editor(dto) - return new_editor + custom_editor_query = """ + INSERT INTO project_custom_editors (project_id, name, description, url) + VALUES (:project_id, :name, :description, :url) + """ + await db.execute( + custom_editor_query, + { + "project_id": project_id, + "name": dto.name, + "description": dto.description, + "url": dto.url, + }, + ) + + async def update_editor(project_id: int, dto: CustomEditorDTO, db: Database): + """Updates existing CustomEditor form DTO using raw SQL""" + query = """ + UPDATE project_custom_editors + SET name = :name, description = :description, url = :url + WHERE project_id = :project_id + """ + + await db.execute( + query, + values={ + "name": dto.name, + "description": dto.description, + "url": dto.url, + "project_id": project_id, + }, + ) - def update_editor(self, dto: CustomEditorDTO): - """Upates existing CustomEditor form DTO""" - self.name = dto.name - self.description = dto.description - self.url = dto.url - self.save() + async def delete(project_id: int, db: Database): + """Deletes the CustomEditor with the given project_id from the DB using raw SQL""" + query = """ + DELETE FROM project_custom_editors + WHERE project_id = :project_id + """ - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + await db.execute(query, values={"project_id": project_id}) def as_dto(self) -> CustomEditorDTO: """Returns the CustomEditor as a DTO""" diff --git a/backend/models/postgis/interests.py b/backend/models/postgis/interests.py index d9c6e0213c..11a1dbe372 100644 --- a/backend/models/postgis/interests.py +++ b/backend/models/postgis/interests.py @@ -1,68 +1,44 @@ -from backend import db -from backend.exceptions import NotFound -from backend.models.dtos.interests_dto import InterestDTO, InterestsListDTO +from databases import Database +from sqlalchemy import BigInteger, Column, ForeignKey, Integer, String, Table, select + +from backend.db import Base +from backend.models.dtos.interests_dto import InterestDTO # Secondary table defining many-to-many join for interests of a user. -user_interests = db.Table( +user_interests = Table( "user_interests", - db.metadata, - db.Column("interest_id", db.Integer, db.ForeignKey("interests.id")), - db.Column("user_id", db.BigInteger, db.ForeignKey("users.id")), + Base.metadata, + Column("interest_id", Integer, ForeignKey("interests.id")), + Column("user_id", BigInteger, ForeignKey("users.id")), ) # Secondary table defining many-to-many join for interests of a project. -project_interests = db.Table( +project_interests = Table( "project_interests", - db.metadata, - db.Column("interest_id", db.Integer, db.ForeignKey("interests.id")), - db.Column("project_id", db.BigInteger, db.ForeignKey("projects.id")), + Base.metadata, + Column("interest_id", Integer, ForeignKey("interests.id")), + Column("project_id", BigInteger, ForeignKey("projects.id")), ) -class Interest(db.Model): +class Interest(Base): """Describes an interest for projects and users""" __tablename__ = "interests" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) + id = Column(Integer, primary_key=True) + name = Column(String, unique=True) @staticmethod - def get_by_id(interest_id: int): + async def get_by_id(interest_id: int, db: Database): """Get interest by id""" - interest = db.session.get(Interest, interest_id) - if interest is None: - raise NotFound(sub_code="INTEREST_NOT_FOUND", interest_id=interest_id) - - return interest - - @staticmethod - def get_by_name(name: str): - """Get interest by name""" - interest = Interest.query.filter(Interest.name == name).first() - if interest is None: - raise NotFound(sub_code="INTEREST_NOT_FOUND", interest_name=name) - - return interest - - def update(self, dto): - """Update existing interest""" - self.name = dto.name - db.session.commit() + query = select(Interest).where(Interest.id == interest_id) + result = await db.fetch_one(query) - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - """Save changes to db""" - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + if result: + # If Interest is a Pydantic model or class, you can instantiate it + return Interest(**result) + return None def as_dto(self) -> InterestDTO: """Get the interest from the DB""" @@ -71,12 +47,3 @@ def as_dto(self) -> InterestDTO: dto.name = self.name return dto - - @staticmethod - def get_all_interests(): - """Get all interests""" - query = Interest.query.all() - interest_list_dto = InterestsListDTO() - interest_list_dto.interests = [interest.as_dto() for interest in query] - - return interest_list_dto diff --git a/backend/models/postgis/licenses.py b/backend/models/postgis/licenses.py index 091e2767cb..84bf2e2361 100644 --- a/backend/models/postgis/licenses.py +++ b/backend/models/postgis/licenses.py @@ -1,81 +1,66 @@ -from backend import db +from databases import Database +from sqlalchemy import BigInteger, Column, ForeignKey, Integer, String, Table +from sqlalchemy.orm import relationship + +from backend.db import Base from backend.exceptions import NotFound -from backend.models.dtos.licenses_dto import LicenseDTO, LicenseListDTO +from backend.models.dtos.licenses_dto import LicenseDTO # Secondary table defining the many-to-many join -user_licenses_table = db.Table( +user_licenses_table = Table( "user_licenses", - db.metadata, - db.Column("user", db.BigInteger, db.ForeignKey("users.id")), - db.Column("license", db.Integer, db.ForeignKey("licenses.id")), + Base.metadata, + Column("user", BigInteger, ForeignKey("users.id")), + Column("license", Integer, ForeignKey("licenses.id")), ) -class License(db.Model): +class License(Base): """Describes an individual license""" __tablename__ = "licenses" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, unique=True) - description = db.Column(db.String) - plain_text = db.Column(db.String) + id = Column(Integer, primary_key=True) + name = Column(String, unique=True) + description = Column(String) + plain_text = Column(String) - projects = db.relationship("Project", backref="license") - users = db.relationship( + projects = relationship("Project", backref="license") + users = relationship( "License", secondary=user_licenses_table ) # Many to Many relationship @staticmethod - def get_by_id(license_id: int): + async def get_by_id(license_id: int, db: Database): """Get license by id""" - map_license = db.session.get(License, license_id) + query = """ + SELECT id AS "licenseId", name, description, plain_text AS "plainText" + FROM licenses + WHERE id = :license_id + """ + map_license = await db.fetch_one(query, {"license_id": license_id}) if map_license is None: raise NotFound(sub_code="LICENSE_NOT_FOUND", license_id=license_id) return map_license - @classmethod - def create_from_dto(cls, dto: LicenseDTO) -> int: + async def create_from_dto(license_dto: LicenseDTO, db: Database) -> int: """Creates a new License class from dto""" - new_license = cls() - new_license.name = dto.name - new_license.description = dto.description - new_license.plain_text = dto.plain_text - - db.session.add(new_license) - db.session.commit() - - return new_license.id - - def update_license(self, dto: LicenseDTO): - """Update existing license""" - self.name = dto.name - self.description = dto.description - self.plain_text = dto.plain_text - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - - @staticmethod - def get_all() -> LicenseListDTO: - """Gets all licenses currently stored""" - results = License.query.all() - - dto = LicenseListDTO() - for result in results: - imagery_license = LicenseDTO() - imagery_license.license_id = result.id - imagery_license.name = result.name - imagery_license.description = result.description - imagery_license.plain_text = result.plain_text - dto.licenses.append(imagery_license) - - return dto + query = """ + INSERT INTO licenses (name, description, plain_text) + VALUES (:name, :description, :plain_text) + RETURNING id + """ + values = { + "name": license_dto.name, + "description": license_dto.description, + "plain_text": license_dto.plain_text, + } + + async with db.transaction(): + new_license_id = await db.execute(query, values) + return new_license_id def as_dto(self) -> LicenseDTO: """Get the license from the DB""" diff --git a/backend/models/postgis/mapping_issues.py b/backend/models/postgis/mapping_issues.py index ef7bc8a3d5..44f0ab8acf 100644 --- a/backend/models/postgis/mapping_issues.py +++ b/backend/models/postgis/mapping_issues.py @@ -1,58 +1,79 @@ -from backend import db +from databases import Database +from sqlalchemy import Boolean, Column, Integer, String, delete, insert, select, update + +from backend.db import Base from backend.models.dtos.mapping_issues_dto import ( - MappingIssueCategoryDTO, MappingIssueCategoriesDTO, + MappingIssueCategoryDTO, ) -class MappingIssueCategory(db.Model): +class MappingIssueCategory(Base): """Represents a category of task mapping issues identified during validaton""" __tablename__ = "mapping_issue_categories" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String, nullable=False, unique=True) - description = db.Column(db.String, nullable=True) - archived = db.Column(db.Boolean, default=False, nullable=False) + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False, unique=True) + description = Column(String, nullable=True) + archived = Column(Boolean, default=False, nullable=False) def __init__(self, name): self.name = name @staticmethod - def get_by_id(category_id: int): + async def get_by_id(category_id: int, db: Database): """Get category by id""" - return db.session.get(MappingIssueCategory, category_id) + query = select(MappingIssueCategory).where( + MappingIssueCategory.id == category_id + ) + return await db.fetch_one(query) @classmethod - def create_from_dto(cls, dto: MappingIssueCategoryDTO) -> int: + async def create_from_dto(cls, dto: MappingIssueCategoryDTO, db: Database) -> int: """Creates a new MappingIssueCategory class from dto""" new_category = cls(dto.name) new_category.description = dto.description - db.session.add(new_category) - db.session.commit() + query = insert(MappingIssueCategory.__table__).values( + name=new_category.name, + description=new_category.description, + archived=dto.archived, + ) + result = await db.execute(query) + return result - return new_category.id - - def update_category(self, dto: MappingIssueCategoryDTO): + async def update_category(self, dto: MappingIssueCategoryDTO, db: Database): """Update existing category""" self.name = dto.name self.description = dto.description if dto.archived is not None: self.archived = dto.archived - db.session.commit() - - def delete(self): + query = ( + update(MappingIssueCategory.__table__) + .where( + MappingIssueCategory.id == self.id, + ) + .values( + name=self.name, description=self.description, archived=self.archived + ) + ) + await db.execute(query) + + async def delete(self, db: Database): """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + query = delete(MappingIssueCategory.__table__).where( + MappingIssueCategory.id == self.id + ) + await db.execute(query) @staticmethod - def get_all_categories(include_archived): - category_query = MappingIssueCategory.query.order_by(MappingIssueCategory.name) + async def get_all_categories(include_archived, db): + query = select(MappingIssueCategory).order_by(MappingIssueCategory.name) + # Apply condition if archived records are to be excluded if not include_archived: - category_query = category_query.filter_by(archived=False) + query = query.where(MappingIssueCategory.archived.is_(False)) - results = category_query.all() + results = await db.fetch_all(query) dto = MappingIssueCategoriesDTO() for result in results: diff --git a/backend/models/postgis/message.py b/backend/models/postgis/message.py index 32516d5a57..f03e977514 100644 --- a/backend/models/postgis/message.py +++ b/backend/models/postgis/message.py @@ -1,14 +1,25 @@ -from sqlalchemy.sql.expression import false - -from backend import db -from flask import current_app from enum import Enum +from databases import Database +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Integer, + String, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql.expression import false + +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.message_dto import MessageDTO, MessagesDTO -from backend.models.postgis.user import User -from backend.models.postgis.task import Task, TaskHistory, TaskAction from backend.models.postgis.project import Project +from backend.models.postgis.task import Task, TaskAction +from backend.models.postgis.user import User from backend.models.postgis.utils import timestamp @@ -30,33 +41,33 @@ class MessageType(Enum): TEAM_BROADCAST = 11 # Broadcast message from a team manager -class Message(db.Model): +class Message(Base): """Describes an individual Message a user can send""" __tablename__ = "messages" __table_args__ = ( - db.ForeignKeyConstraint( + ForeignKeyConstraint( ["task_id", "project_id"], ["tasks.id", "tasks.project_id"] ), ) - id = db.Column(db.Integer, primary_key=True) - message = db.Column(db.String) - subject = db.Column(db.String) - from_user_id = db.Column(db.BigInteger, db.ForeignKey("users.id")) - to_user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), index=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), index=True) - task_id = db.Column(db.Integer, index=True) - message_type = db.Column(db.Integer, index=True) - date = db.Column(db.DateTime, default=timestamp) - read = db.Column(db.Boolean, default=False) + id = Column(Integer, primary_key=True) + message = Column(String) + subject = Column(String) + from_user_id = Column(BigInteger, ForeignKey("users.id")) + to_user_id = Column(BigInteger, ForeignKey("users.id"), index=True) + project_id = Column(Integer, ForeignKey("projects.id"), index=True) + task_id = Column(Integer, index=True) + message_type = Column(Integer, index=True) + date = Column(DateTime, default=timestamp) + read = Column(Boolean, default=False) # Relationships - from_user = db.relationship(User, foreign_keys=[from_user_id]) - to_user = db.relationship(User, foreign_keys=[to_user_id], backref="messages") - project = db.relationship(Project, foreign_keys=[project_id], backref="messages") - task = db.relationship( + from_user = relationship(User, foreign_keys=[from_user_id]) + to_user = relationship(User, foreign_keys=[to_user_id], backref="messages") + project = relationship(Project, foreign_keys=[project_id], backref="messages") + task = relationship( Task, primaryjoin="and_(Task.id == foreign(Message.task_id), Task.project_id == Message.project_id)", backref="messages", @@ -72,6 +83,8 @@ def from_dto(cls, to_user_id: int, dto: MessageDTO): message.to_user_id = to_user_id message.project_id = dto.project_id message.task_id = dto.task_id + message.date = timestamp() + message.read = False if dto.message_type is not None: message.message_type = MessageType(dto.message_type) @@ -98,52 +111,67 @@ def as_dto(self) -> MessageDTO: return dto - def add_message(self): - """Add message into current transaction - DO NOT COMMIT HERE AS MESSAGES ARE PART OF LARGER TRANSACTIONS""" - current_app.logger.debug("Adding message to session") - db.session.add(self) - - def save(self): + async def save(self, db: Database): """Save""" - db.session.add(self) - db.session.commit() + await db.execute( + Message.__table__.insert().values( + subject=self.subject, + message=self.message, + from_user_id=self.from_user_id, + to_user_id=self.to_user_id, + project_id=self.project_id, + task_id=self.task_id, + message_type=self.message_type, + read=self.read, + date=self.date, + ) + ) @staticmethod - def get_all_contributors(project_id: int): - """Get all contributors to a project""" - - contributors = ( - db.session.query(Task.mapped_by) - .filter(Task.project_id == project_id) - .filter(Task.mapped_by.isnot(None)) - .union( - db.session.query(Task.validated_by) - .filter(Task.project_id == project_id) - .filter(Task.validated_by.isnot(None)) - ) - .distinct() - ).all() + async def get_all_contributors(project_id: int, db: Database): + """Get all contributors to a project using async raw SQL""" + + query = """ + SELECT DISTINCT contributor + FROM ( + SELECT mapped_by AS contributor + FROM tasks + WHERE project_id = :project_id + AND mapped_by IS NOT NULL + UNION + SELECT validated_by AS contributor + FROM tasks + WHERE project_id = :project_id + AND validated_by IS NOT NULL + ) AS contributors + """ + + rows = await db.fetch_all(query=query, values={"project_id": project_id}) + + contributors = [row["contributor"] for row in rows] return contributors @staticmethod - def get_all_tasks_contributors(project_id: int, task_id: int): + async def get_all_tasks_contributors(project_id: int, task_id: int, db: Database): """Get all contributors of a task""" - contributors = ( - TaskHistory.query.distinct(TaskHistory.user_id) - .filter(TaskHistory.project_id == project_id) - .filter(TaskHistory.task_id == task_id) - .filter(TaskHistory.action != TaskAction.COMMENT.name) - .all() + query = """ + SELECT DISTINCT u.username + FROM task_history th + JOIN users u ON th.user_id = u.id + WHERE th.project_id = :project_id + AND th.task_id = :task_id + AND th.action != :comment_action + """ + contributors = await db.fetch_all( + query, + { + "project_id": project_id, + "task_id": task_id, + "comment_action": TaskAction.COMMENT.name, + }, ) - contributors = [ - contributor.actioned_by.username for contributor in contributors - ] - return contributors - def mark_as_read(self): - """Mark the message in scope as Read""" - self.read = True - db.session.commit() + return [contributor["username"] for contributor in contributors] @staticmethod def get_unread_message_count(user_id: int): @@ -170,56 +198,75 @@ def get_all_messages(user_id: int) -> MessagesDTO: return messages_dto @staticmethod - def delete_multiple_messages(message_ids: list, user_id: int): + async def delete_multiple_messages(message_ids: list, user_id: int, db: Database): """Deletes the specified messages to the user""" - Message.query.filter( - Message.to_user_id == user_id, Message.id.in_(message_ids) - ).delete(synchronize_session=False) - db.session.commit() + delete_query = """ + DELETE FROM messages + WHERE to_user_id = :user_id AND id = ANY(:message_ids) + """ + await db.execute(delete_query, {"user_id": user_id, "message_ids": message_ids}) @staticmethod - def delete_all_messages(user_id: int, message_type_filters: list = None): + async def delete_all_messages( + user_id: int, db: Database, message_type_filters: list = None + ): """Deletes all messages to the user ----------------------------------- :param user_id: user id of the user whose messages are to be deleted :param message_type_filters: list of message types to filter by returns: None """ - query = Message.query.filter(Message.to_user_id == user_id) - if message_type_filters: - query = query.filter(Message.message_type.in_(message_type_filters)) - query.delete(synchronize_session=False) - db.session.commit() + delete_query = """ + DELETE FROM messages + WHERE to_user_id = :user_id + """ + params = {"user_id": user_id} - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + if message_type_filters: + delete_query += " AND message_type = ANY(:message_type_filters)" + params["message_type_filters"] = message_type_filters + await db.execute(delete_query, params) @staticmethod - def mark_multiple_messages_read(message_ids: list, user_id: int): + async def mark_multiple_messages_read( + message_ids: list, user_id: int, db: Database + ): """Marks the specified messages as read ---------------------------------------- :param message_ids: list of message ids to mark as read :param user_id: user id of the user who is marking the messages as read + :param db: database connection """ - Message.query.filter( - Message.to_user_id == user_id, Message.id.in_(message_ids) - ).update({Message.read: True}, synchronize_session=False) - db.session.commit() + async with db.transaction(): + query = """ + UPDATE messages + SET read = True + WHERE to_user_id = :user_id AND id = ANY(:message_ids) + """ + await db.execute(query, {"user_id": user_id, "message_ids": message_ids}) @staticmethod - def mark_all_messages_read(user_id: int, message_type_filters: list = None): + async def mark_all_messages_read( + user_id: int, db: Database, message_type_filters: list = None + ): """Marks all messages as read ---------------------------------------- :param user_id: user id of the user who is marking the messages as read + :param db: database connection :param message_type_filters: list of message types to filter by """ - # https://docs.sqlalchemy.org/en/13/orm/query.html#sqlalchemy.orm.query.Query.update - query = Message.query.filter( - Message.to_user_id == user_id, Message.read == false() - ) - if message_type_filters: - query = query.filter(Message.message_type.in_(message_type_filters)) - query.update({Message.read: True}, synchronize_session=False) - db.session.commit() + async with db.transaction(): + query = """ + UPDATE messages + SET read = TRUE + WHERE to_user_id = :user_id + AND read = FALSE + """ + + params = {"user_id": user_id} + + if message_type_filters: + query += " AND message_type = ANY(:message_type_filters)" + params["message_type_filters"] = message_type_filters + + await db.execute(query, params) diff --git a/backend/models/postgis/notification.py b/backend/models/postgis/notification.py index 66a8fcf5ea..0ffa718022 100644 --- a/backend/models/postgis/notification.py +++ b/backend/models/postgis/notification.py @@ -1,25 +1,36 @@ -from backend import db +from datetime import datetime, timedelta + +from databases import Database +from sqlalchemy import ( + BigInteger, + Column, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Integer, +) +from sqlalchemy.orm import relationship + +from backend.db import Base +from backend.models.dtos.notification_dto import NotificationDTO from backend.models.postgis.user import User -from backend.models.postgis.message import Message from backend.models.postgis.utils import timestamp -from backend.models.dtos.notification_dto import NotificationDTO -from datetime import datetime, timedelta -class Notification(db.Model): +class Notification(Base): """Describes a Notification for a user""" __tablename__ = "notifications" - __table_args__ = (db.ForeignKeyConstraint(["user_id"], ["users.id"]),) + __table_args__ = (ForeignKeyConstraint(["user_id"], ["users.id"]),) - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), index=True) - unread_count = db.Column(db.Integer) - date = db.Column(db.DateTime, default=timestamp) + id = Column(Integer, primary_key=True) + user_id = Column(BigInteger, ForeignKey("users.id"), index=True) + unread_count = Column(Integer) + date = Column(DateTime, default=timestamp) # Relationships - user = db.relationship(User, foreign_keys=[user_id], backref="notifications") + user = relationship(User, foreign_keys=[user_id], backref="notifications") def as_dto(self) -> NotificationDTO: """Casts notification object to DTO""" @@ -30,35 +41,38 @@ def as_dto(self) -> NotificationDTO: return dto - def save(self): - db.session.add(self) - db.session.commit() - - def update(self): - self.date = timestamp() - db.session.commit() - @staticmethod - def get_unread_message_count(user_id: int) -> int: + async def get_unread_message_count(user_id: int, db: Database) -> int: """Get count of unread messages for user""" - notifications = Notification.query.filter( - Notification.user_id == user_id - ).first() + query = """ + SELECT unread_count, date + FROM notifications + WHERE user_id = :user_id + ORDER BY id + LIMIT 1 + """ + notification = await db.fetch_one(query, {"user_id": user_id}) - # Create if does not exist. - if notifications is None: - # In case users are new but have not logged in previously. - date_value = datetime.today() - timedelta(days=30) - notifications = Notification( - user_id=user_id, unread_count=0, date=date_value + if notification is None: + date_value = datetime.utcnow() - timedelta(days=30) + insert_query = """ + INSERT INTO notifications (user_id, unread_count, date) + VALUES (:user_id, :unread_count, :date) + """ + await db.execute( + insert_query, + {"user_id": user_id, "unread_count": 0, "date": date_value}, ) - notifications.save() + else: + date_value = notification["date"] - # Count messages that the user has received after last check. - count = ( - Message.query.filter_by(to_user_id=user_id, read=False) - .filter(Message.date > notifications.date) - .count() + message_query = """ + SELECT COUNT(*) + FROM messages + WHERE to_user_id = :user_id AND read = False AND date > :date_value + """ + count = await db.fetch_val( + message_query, {"user_id": user_id, "date_value": date_value} ) return count diff --git a/backend/models/postgis/organisation.py b/backend/models/postgis/organisation.py index c19a54a4d8..ce0ad9ee01 100644 --- a/backend/models/postgis/organisation.py +++ b/backend/models/postgis/organisation.py @@ -1,26 +1,36 @@ +from databases import Database +from fastapi import HTTPException from slugify import slugify +from sqlalchemy import ( + BigInteger, + Column, + ForeignKey, + Integer, + String, + Table, + UniqueConstraint, +) +from sqlalchemy.orm import backref, relationship -from backend import db +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.organisation_dto import ( - OrganisationDTO, NewOrganisationDTO, + OrganisationDTO, OrganisationManagerDTO, + UpdateOrganisationDTO, ) -from backend.models.postgis.user import User from backend.models.postgis.campaign import Campaign, campaign_organisations from backend.models.postgis.statuses import OrganisationType - +from backend.models.postgis.user import User # Secondary table defining many-to-many relationship between organisations and managers -organisation_managers = db.Table( +organisation_managers = Table( "organisation_managers", - db.metadata, - db.Column( - "organisation_id", db.Integer, db.ForeignKey("organisations.id"), nullable=False - ), - db.Column("user_id", db.BigInteger, db.ForeignKey("users.id"), nullable=False), - db.UniqueConstraint("organisation_id", "user_id", name="organisation_user_key"), + Base.metadata, + Column("organisation_id", Integer, ForeignKey("organisations.id"), nullable=False), + Column("user_id", BigInteger, ForeignKey("users.id"), nullable=False), + UniqueConstraint("organisation_id", "user_id", name="organisation_user_key"), ) @@ -28,163 +38,265 @@ class InvalidRoleException(Exception): pass -class Organisation(db.Model): +class Organisation(Base): """Describes an Organisation""" __tablename__ = "organisations" # Columns - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(512), nullable=False, unique=True) - slug = db.Column(db.String(255), nullable=False, unique=True) - logo = db.Column(db.String) # URL of a logo - description = db.Column(db.String) - url = db.Column(db.String) - type = db.Column(db.Integer, default=OrganisationType.FREE.value, nullable=False) - subscription_tier = db.Column(db.Integer) - - managers = db.relationship( + id = Column(Integer, primary_key=True) + name = Column(String(512), nullable=False, unique=True) + slug = Column(String(255), nullable=False, unique=True) + logo = Column(String) # URL of a logo + description = Column(String) + url = Column(String) + type = Column(Integer, default=OrganisationType.FREE.value, nullable=False) + subscription_tier = Column(Integer) + + managers = relationship( User, secondary=organisation_managers, - backref=db.backref("organisations", lazy="joined"), + backref=backref("organisations"), ) - campaign = db.relationship( + campaign = relationship( Campaign, secondary=campaign_organisations, backref="organisation" ) - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - db.session.commit() - - @classmethod - def create_from_dto(cls, new_organisation_dto: NewOrganisationDTO): - """Creates a new organisation from a DTO""" - new_org = cls() - - new_org.name = new_organisation_dto.name - new_org.slug = new_organisation_dto.slug or slugify(new_organisation_dto.name) - new_org.logo = new_organisation_dto.logo - new_org.description = new_organisation_dto.description - new_org.url = new_organisation_dto.url - new_org.type = OrganisationType[new_organisation_dto.type].value - new_org.subscription_tier = new_organisation_dto.subscription_tier - - for manager in new_organisation_dto.managers: - user = User.get_by_username(manager) - - if user is None: - raise NotFound(sub_code="USER_NOT_FOUND", username=manager) - - new_org.managers.append(user) - - new_org.create() - return new_org - - def update(self, organisation_dto: OrganisationDTO): - """Updates Organisation from DTO""" - - for attr, value in organisation_dto.items(): - if attr == "type" and value is not None: - value = OrganisationType[organisation_dto.type].value - if attr == "managers": - continue - - try: - is_field_nullable = self.__table__.columns[attr].nullable - if is_field_nullable and value is not None: - setattr(self, attr, value) - elif value is not None: - setattr(self, attr, value) - except KeyError: - continue - - if organisation_dto.managers: - self.managers = [] - # Need to handle this in the loop so we can take care of NotFound users - for manager in organisation_dto.managers: - new_manager = User.get_by_username(manager) - - if new_manager is None: + async def create_from_dto(new_organisation_dto: NewOrganisationDTO, db: Database): + """Creates a new organisation from a DTO and associates managers""" + slug = new_organisation_dto.slug or slugify(new_organisation_dto.name) + query = """ + INSERT INTO organisations (name, slug, logo, description, url, type, subscription_tier) + VALUES (:name, :slug, :logo, :description, :url, :type, :subscription_tier) + RETURNING id + """ + values = { + "name": new_organisation_dto.name, + "slug": slug, + "logo": new_organisation_dto.logo, + "description": new_organisation_dto.description, + "url": new_organisation_dto.url, + "type": OrganisationType[new_organisation_dto.type].value, + "subscription_tier": new_organisation_dto.subscription_tier, + } + + try: + organisation_id = await db.execute(query, values) + + for manager in new_organisation_dto.managers: + user_query = "SELECT id FROM users WHERE username = :username" + user = await db.fetch_one(user_query, {"username": manager}) + + if not user: raise NotFound(sub_code="USER_NOT_FOUND", username=manager) - self.managers.append(new_manager) + manager_query = """ + INSERT INTO organisation_managers (organisation_id, user_id) + VALUES (:organisation_id, :user_id) + """ + await db.execute( + manager_query, + {"organisation_id": organisation_id, "user_id": user.id}, + ) - db.session.commit() + return organisation_id - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e - def can_be_deleted(self) -> bool: - """An Organisation can be deleted if it doesn't have any projects or teams""" - return len(self.projects) == 0 and len(self.teams) == 0 + async def update(organisation_dto: UpdateOrganisationDTO, db: Database): + """Updates Organisation from DTO""" + try: + org_id = organisation_dto.organisation_id + org_dict = organisation_dto.dict(exclude_unset=True) + if "type" in org_dict and org_dict["type"] is not None: + org_dict["type"] = OrganisationType[org_dict["type"].upper()].value + + update_keys = { + key: org_dict[key] + for key in org_dict.keys() + if key not in ["organisation_id", "managers"] + } + set_clause = ", ".join(f"{key} = :{key}" for key in update_keys.keys()) + if set_clause: + update_query = f""" + UPDATE organisations + SET {set_clause} + WHERE id = :id + """ + await db.execute(update_query, values={**update_keys, "id": org_id}) + + if organisation_dto.managers: + clear_managers_query = """ + DELETE FROM organisation_managers + WHERE organisation_id = :id + """ + await db.execute(clear_managers_query, values={"id": org_id}) + for manager_username in organisation_dto.managers: + user_query = "SELECT id FROM users WHERE username = :username" + user = await db.fetch_one( + user_query, {"username": manager_username} + ) + + if not user: + raise NotFound( + sub_code="USER_NOT_FOUND", username=manager_username + ) + + insert_manager_query = """ + INSERT INTO organisation_managers (organisation_id, user_id) + VALUES (:organisation_id, :user_id) + """ + await db.execute( + insert_manager_query, + {"organisation_id": org_id, "user_id": user.id}, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + async def can_be_deleted(organisation_id: int, db) -> bool: + # Check if the organization has any projects + projects_query = """ + SELECT COUNT(*) + FROM projects + WHERE organisation_id = :organisation_id + """ + projects_count = await db.fetch_val( + projects_query, values={"organisation_id": organisation_id} + ) + # Check if the organization has any teams + teams_query = """ + SELECT COUNT(*) + FROM teams + WHERE organisation_id = :organisation_id + """ + teams_count = await db.fetch_val( + teams_query, values={"organisation_id": organisation_id} + ) + # Organisation can be deleted if it has no projects and no teams + return projects_count == 0 and teams_count == 0 @staticmethod - def get(organisation_id: int): + async def get(organisation_id: int, db: Database): """ Gets specified organisation by id :param organisation_id: organisation ID in scope :return: Organisation if found otherwise None """ - return db.session.get(Organisation, organisation_id) + organization = await db.fetch_one( + "SELECT * FROM organisations WHERE id = :id", values={"id": organisation_id} + ) + return organization["id"] if organization else None @staticmethod - def get_organisation_by_name(organisation_name: str): + async def get_organisation_by_name(organisation_name: str, db: Database): """Get organisation by name :param organisation_name: name of organisation :return: Organisation if found else None """ - return Organisation.query.filter_by(name=organisation_name).first() - - @staticmethod - def get_organisation_name_by_id(organisation_id: int): - """Get organisation name by id - :param organisation_id: - :return: Organisation name + query = """ + SELECT * FROM organisations + WHERE name = :name """ - return Organisation.query.get(organisation_id).name + + result = await db.fetch_one(query, values={"name": organisation_name}) + return result if result else None @staticmethod - def get_all_organisations(): + async def get_all_organisations(db: Database): """Gets all organisations""" - return Organisation.query.order_by(Organisation.name).all() + query = """ + SELECT + o.id AS organisation_id, + o.name, + o.slug, + o.logo, + o.description, + o.url, + CASE + WHEN o.type = 1 THEN 'FREE' + WHEN o.type = 2 THEN 'DISCOUNTED' + WHEN o.type = 3 THEN 'FULL_FEE' + ELSE 'UNKNOWN' + END AS type, + o.subscription_tier, + COALESCE( + json_agg( + json_build_object( + 'id', u.id, + 'username', u.username, + 'picture_url', u.picture_url + ) + ) FILTER (WHERE u.id IS NOT NULL), '[]' + ) AS managers + FROM organisations o + LEFT JOIN organisation_managers om ON o.id = om.organisation_id + LEFT JOIN users u ON om.user_id = u.id + GROUP BY o.id + ORDER BY o.name + """ + result = await db.fetch_all(query) + return result @staticmethod - def get_organisations_managed_by_user(user_id: int): + async def get_organisations_managed_by_user(user_id: int, db: Database): """Gets organisations a user can manage""" - query_results = ( - Organisation.query.join(organisation_managers) - .filter( - (organisation_managers.c.organisation_id == Organisation.id) - & (organisation_managers.c.user_id == user_id) - ) - .order_by(Organisation.name) - .all() - ) - return query_results + query = f""" + SELECT + o.id AS organisation_id, + o.name, + o.slug, + o.logo, + o.description, + o.url, + CASE + WHEN o.type = {OrganisationType.FREE.value} THEN 'FREE' + WHEN o.type = {OrganisationType.DISCOUNTED.value} THEN 'DISCOUNTED' + WHEN o.type = {OrganisationType.FULL_FEE.value} THEN 'FULL_FEE' + ELSE 'UNKNOWN' + END AS type, + o.subscription_tier, + COALESCE( + json_agg( + json_build_object( + 'id', u.id, + 'username', u.username, + 'picture_url', u.picture_url + ) + ) FILTER (WHERE u.id IS NOT NULL), '[]' + ) AS managers + FROM organisations o + LEFT JOIN organisation_managers om ON o.id = om.organisation_id + LEFT JOIN users u ON om.user_id = u.id + WHERE om.user_id = :user_id -- Filter organisations by the user who manages them + GROUP BY o.id + ORDER BY o.name + """ + params = {"user_id": user_id} + result = await db.fetch_all(query, values=params) + return result + + async def fetch_managers(self, session): + """Fetch managers asynchronously""" + await session.refresh(self, ["managers"]) - def as_dto(self, omit_managers=False): + def as_dto(org, omit_managers=False): """Returns a dto for an organisation""" organisation_dto = OrganisationDTO() - organisation_dto.organisation_id = self.id - organisation_dto.name = self.name - organisation_dto.slug = self.slug - organisation_dto.logo = self.logo - organisation_dto.description = self.description - organisation_dto.url = self.url + organisation_dto.organisation_id = org.organisation_id + organisation_dto.name = org.name + organisation_dto.slug = org.slug + organisation_dto.logo = org.logo + organisation_dto.description = org.description + organisation_dto.url = org.url organisation_dto.managers = [] - organisation_dto.type = OrganisationType(self.type).name - organisation_dto.subscription_tier = self.subscription_tier + organisation_dto.type = org.type + organisation_dto.subscription_tier = org.subscription_tier if omit_managers: return organisation_dto - for manager in self.managers: + for manager in org.managers: org_manager_dto = OrganisationManagerDTO() org_manager_dto.username = manager.username org_manager_dto.picture_url = manager.picture_url diff --git a/backend/models/postgis/partner.py b/backend/models/postgis/partner.py index 507f0fb4df..27c35d696d 100644 --- a/backend/models/postgis/partner.py +++ b/backend/models/postgis/partner.py @@ -1,58 +1,57 @@ -from backend import db import json +from typing import Optional + +from databases import Database +from sqlalchemy import Column, Integer, String + +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.partner_dto import PartnerDTO -class Partner(db.Model): - """Model for Partners""" +class Partner(Base): + """Describes a Partner""" __tablename__ = "partners" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - name = db.Column(db.String(150), nullable=False, unique=True) - primary_hashtag = db.Column(db.String(200), nullable=False) - secondary_hashtag = db.Column(db.String(200)) - logo_url = db.Column(db.String(500)) - link_meta = db.Column(db.String(300)) - link_x = db.Column(db.String(300)) - link_instagram = db.Column(db.String(300)) - current_projects = db.Column(db.String) - permalink = db.Column(db.String(500), unique=True) - website_links = db.Column(db.String) - mapswipe_group_id = db.Column(db.String, nullable=True) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - """Save changes to DB""" - db.session.commit() - - def delete(self): - """Deletes from the DB""" - db.session.delete(self) - db.session.commit() + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(150), nullable=False, unique=True) + primary_hashtag = Column(String(200), nullable=False) + secondary_hashtag = Column(String(200), nullable=True) + logo_url = Column(String(500), nullable=True) + link_meta = Column(String(300), nullable=True) + link_x = Column(String(300), nullable=True) # Formerly link_twitter + link_instagram = Column(String(300), nullable=True) + current_projects = Column(String, nullable=True) + permalink = Column(String(500), unique=True, nullable=True) + website_links = Column(String, nullable=True) + mapswipe_group_id = Column(String, nullable=True) @staticmethod - def get_all_partners(): - """Get all partners in DB""" - return db.session.query(Partner.id).all() + async def get_all_partners(db: Database): + """ + Retrieve all partner IDs + """ + query = "SELECT id FROM partners" + results = await db.fetch_all(query) + return [row["id"] for row in results] @staticmethod - def get_by_permalink(permalink: str): - """Get partner by permalink""" - return Partner.query.filter_by(permalink=permalink).one_or_none() + async def get_by_permalink(permalink: str, db: Database) -> Optional[PartnerDTO]: + """Get partner by permalink using raw SQL.""" + query = "SELECT * FROM partners WHERE permalink = :permalink" + result = await db.fetch_one(query, values={"permalink": permalink}) + if result is None: + raise NotFound(sub_code="PARTNER_NOT_FOUND", permalink=permalink) + return result @staticmethod - def get_by_id(partner_id: int): - """Get partner by id""" - partner = db.session.get(Partner, partner_id) - if partner is None: + async def get_by_id(partner_id: int, db: Database) -> PartnerDTO: + query = "SELECT * FROM partners WHERE id = :partner_id" + result = await db.fetch_one(query, values={"partner_id": partner_id}) + if result is None: raise NotFound(sub_code="PARTNER_NOT_FOUND", partner_id=partner_id) - return partner + return result def as_dto(self) -> PartnerDTO: """Creates partner from DTO""" diff --git a/backend/models/postgis/priority_area.py b/backend/models/postgis/priority_area.py index c38a950de7..9afc62210c 100644 --- a/backend/models/postgis/priority_area.py +++ b/backend/models/postgis/priority_area.py @@ -1,32 +1,39 @@ -import geojson import json -from backend import db + +import geojson +from databases import Database from geoalchemy2 import Geometry -from backend.models.postgis.utils import InvalidGeoJson, ST_SetSRID, ST_GeomFromGeoJSON +from sqlalchemy import Column, ForeignKey, Integer, Table + +from backend.db import Base +from backend.models.postgis.utils import InvalidGeoJson # Priority areas aren't shared, however, this arch was taken from TM2 to ease data migration -project_priority_areas = db.Table( +project_priority_areas = Table( "project_priority_areas", - db.metadata, - db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), - db.Column("priority_area_id", db.Integer, db.ForeignKey("priority_areas.id")), + Base.metadata, + Column("project_id", Integer, ForeignKey("projects.id")), + Column("priority_area_id", Integer, ForeignKey("priority_areas.id")), ) -class PriorityArea(db.Model): +class PriorityArea(Base): """Describes an individual priority area""" __tablename__ = "priority_areas" - id = db.Column(db.Integer, primary_key=True) - geometry = db.Column(Geometry("POLYGON", srid=4326)) + id = Column(Integer, primary_key=True) + geometry = Column(Geometry("POLYGON", srid=4326)) @classmethod - def from_dict(cls, area_poly: dict): - """Create a new Priority Area from dictionary""" + async def from_dict(cls, area_poly: dict, db: Database): + """Create a new Priority Area from dictionary and insert into the database.""" + + # Load GeoJSON from the dictionary pa_geojson = geojson.loads(json.dumps(area_poly)) - if type(pa_geojson) is not geojson.Polygon: + # Ensure it's a valid Polygon + if not isinstance(pa_geojson, geojson.Polygon): raise InvalidGeoJson("Priority Areas must be supplied as Polygons") if not pa_geojson.is_valid: @@ -34,13 +41,36 @@ def from_dict(cls, area_poly: dict): "Priority Area: Invalid Polygon - " + ", ".join(pa_geojson.errors()) ) - pa = cls() + # Convert the GeoJSON into WKT format using a raw SQL query valid_geojson = geojson.dumps(pa_geojson) - pa.geometry = ST_SetSRID(ST_GeomFromGeoJSON(valid_geojson), 4326) - return pa - - def get_as_geojson(self): - """Helper to translate geometry back to a GEOJson Poly""" - with db.engine.connect() as conn: - pa_geojson = conn.execute(self.geometry.ST_AsGeoJSON()).scalar() - return geojson.loads(pa_geojson) + geo_query = """ + SELECT ST_AsText( + ST_SetSRID( + ST_GeomFromGeoJSON(:geojson), 4326 + ) + ) AS geometry_wkt; + """ + result = await db.fetch_one(query=geo_query, values={"geojson": valid_geojson}) + geometry_wkt = result["geometry_wkt"] if result else None + + if not geometry_wkt: + raise InvalidGeoJson("Failed to create geometry from the given GeoJSON") + + # Insert the new Priority Area into the database and return the inserted ID + insert_query = """ + INSERT INTO priority_areas (geometry) + VALUES (ST_GeomFromText(:geometry, 4326)) + RETURNING id; + """ + insert_result = await db.fetch_one( + query=insert_query, values={"geometry": geometry_wkt} + ) + + if insert_result: + # Assign the ID and geometry to the PriorityArea object + pa = cls() + pa.id = insert_result["id"] + pa.geometry = geometry_wkt + return pa + else: + raise Exception("Failed to insert Priority Area") diff --git a/backend/models/postgis/project.py b/backend/models/postgis/project.py index 440edff4cf..e99552f85c 100644 --- a/backend/models/postgis/project.py +++ b/backend/models/postgis/project.py @@ -1,181 +1,194 @@ import json +import os import re from typing import Optional -from cachetools import TTLCache, cached import geojson -import datetime -from flask import current_app -from geoalchemy2 import Geometry +import requests +from cachetools import TTLCache +from databases import Database +from fastapi import HTTPException +from geoalchemy2 import Geometry, WKTElement from geoalchemy2.shape import to_shape -from sqlalchemy.sql.expression import cast, or_ -from sqlalchemy import desc, func, Time, orm, literal +from loguru import logger +from shapely import wkb from shapely.geometry import shape +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Table, + delete, + func, + inspect, + orm, + select, + update, +) from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import backref, relationship -import requests - -from backend import db +from backend.config import settings +from backend.db import Base from backend.exceptions import NotFound -from backend.models.dtos.campaign_dto import CampaignDTO +from backend.models.dtos.campaign_dto import CampaignDTO, ListCampaignDTO +from backend.models.dtos.interests_dto import InterestDTO from backend.models.dtos.project_dto import ( - ProjectDTO, + CustomEditorDTO, DraftProjectDTO, - ProjectSummary, PMDashboardDTO, - ProjectStatsDTO, - ProjectUserStatsDTO, + ProjectDTO, + ProjectInfoDTO, ProjectSearchDTO, + ProjectStatsDTO, + ProjectSummary, ProjectTeamDTO, - ProjectInfoDTO, + ProjectUserStatsDTO, ) -from backend.models.dtos.interests_dto import InterestDTO - from backend.models.dtos.tags_dto import TagsDTO -from backend.models.postgis.organisation import Organisation +from backend.models.postgis.campaign import Campaign, campaign_projects from backend.models.postgis.custom_editors import CustomEditor +from backend.models.postgis.interests import Interest, project_interests +from backend.models.postgis.organisation import Organisation from backend.models.postgis.priority_area import PriorityArea, project_priority_areas -from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.project_chat import ProjectChat +from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.statuses import ( - ProjectStatus, - ProjectPriority, - TaskStatus, + Editors, + MappingPermission, MappingTypes, + ProjectDifficulty, + ProjectPriority, + ProjectStatus, TaskCreationMode, - Editors, + TaskStatus, TeamRoles, - MappingPermission, ValidationPermission, - ProjectDifficulty, ) -from backend.models.postgis.task import Task, TaskHistory +from backend.models.postgis.task import Task from backend.models.postgis.team import Team from backend.models.postgis.user import User -from backend.models.postgis.campaign import Campaign, campaign_projects - -from backend.models.postgis.utils import ( - ST_SetSRID, - ST_GeomFromGeoJSON, - timestamp, - ST_Centroid, -) +from backend.models.postgis.utils import timestamp from backend.services.grid.grid_service import GridService -from backend.models.postgis.interests import Interest, project_interests -import os # Secondary table defining many-to-many join for projects that were favorited by users. -project_favorites = db.Table( +project_favorites = Table( "project_favorites", - db.metadata, - db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), - db.Column("user_id", db.BigInteger, db.ForeignKey("users.id")), + Base.metadata, + Column("project_id", Integer, ForeignKey("projects.id")), + Column("user_id", BigInteger, ForeignKey("users.id")), ) # Secondary table defining many-to-many join for private projects that only defined users can map on -project_allowed_users = db.Table( +project_allowed_users = Table( "project_allowed_users", - db.metadata, - db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), - db.Column("user_id", db.BigInteger, db.ForeignKey("users.id")), + Base.metadata, + Column("project_id", Integer, ForeignKey("projects.id")), + Column("user_id", BigInteger, ForeignKey("users.id")), ) -class ProjectTeams(db.Model): +class ProjectTeams(Base): __tablename__ = "project_teams" - team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) - role = db.Column(db.Integer, nullable=False) + team_id = Column(Integer, ForeignKey("teams.id"), primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) + role = Column(Integer, nullable=False) - project = db.relationship( - "Project", backref=db.backref("teams", cascade="all, delete-orphan") - ) - team = db.relationship( - Team, backref=db.backref("projects", cascade="all, delete-orphan") + project = relationship( + "Project", backref=backref("teams", cascade="all, delete-orphan") ) + team = relationship(Team, backref=backref("projects", cascade="all, delete-orphan")) - def create(self): + async def create(self, db: Database): """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - """Save changes to db""" - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + await db.execute( + self.__table__.insert().values( + team_id=self.team_id, project_id=self.project_id, role=self.role + ) + ) # cache mapper counts for 30 seconds active_mappers_cache = TTLCache(maxsize=1024, ttl=30) -class Project(db.Model): +class Project(Base): """Describes a HOT Mapping Project""" __tablename__ = "projects" + def __init__(self, **kwargs): + # First, initialize with provided kwargs + super().__init__(**kwargs) + + # Then dynamically set defaults for any fields that are None + for column in self.__table__.columns: + if getattr(self, column.name) is None and column.default is not None: + # Retrieve the default value from the column + default_value = column.default.arg + setattr(self, column.name, default_value) + # Columns - id = db.Column(db.Integer, primary_key=True) - status = db.Column(db.Integer, default=ProjectStatus.DRAFT.value, nullable=False) - created = db.Column(db.DateTime, default=timestamp, nullable=False) - priority = db.Column(db.Integer, default=ProjectPriority.MEDIUM.value) - default_locale = db.Column( - db.String(10), default="en" + id = Column(Integer, primary_key=True) + status = Column(Integer, default=ProjectStatus.DRAFT.value, nullable=False) + created = Column(DateTime, default=timestamp(), nullable=False) + priority = Column(Integer, default=ProjectPriority.MEDIUM.value) + default_locale = Column( + String(10), default="en" ) # The locale that is returned if requested locale not available - author_id = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users"), nullable=False + author_id = Column( + BigInteger, ForeignKey("users.id", name="fk_users"), nullable=False ) - difficulty = db.Column( - db.Integer, default=2, nullable=False, index=True + difficulty = Column( + Integer, default=2, nullable=False, index=True ) # Mapper level project is suitable for - mapping_permission = db.Column(db.Integer, default=MappingPermission.ANY.value) - validation_permission = db.Column( - db.Integer, default=ValidationPermission.LEVEL.value + mapping_permission = Column(Integer, default=MappingPermission.ANY.value) + validation_permission = Column( + Integer, default=ValidationPermission.LEVEL.value ) # Means only users with validator role can validate - enforce_random_task_selection = db.Column( - db.Boolean, default=False + enforce_random_task_selection = Column( + Boolean, default=False ) # Force users to edit at random to avoid mapping "easy" tasks - private = db.Column(db.Boolean, default=False) # Only allowed users can validate - featured = db.Column( - db.Boolean, default=False - ) # Only PMs can set a project as featured - changeset_comment = db.Column(db.String) - osmcha_filter_id = db.Column( - db.String + private = Column(Boolean, default=False) # Only allowed users can validate + featured = Column(Boolean, default=False) # Only PMs can set a project as featured + changeset_comment = Column(String) + osmcha_filter_id = Column( + String ) # Optional custom filter id for filtering on OSMCha - due_date = db.Column(db.DateTime) - imagery = db.Column(db.String) - josm_preset = db.Column(db.String) - id_presets = db.Column(ARRAY(db.String)) - extra_id_params = db.Column(db.String) - rapid_power_user = db.Column(db.Boolean, default=False) - last_updated = db.Column(db.DateTime, default=timestamp) - progress_email_sent = db.Column(db.Boolean, default=False) - license_id = db.Column(db.Integer, db.ForeignKey("licenses.id", name="fk_licenses")) - geometry = db.Column(Geometry("MULTIPOLYGON", srid=4326), nullable=False) - centroid = db.Column(Geometry("POINT", srid=4326), nullable=False) - country = db.Column(ARRAY(db.String), default=[]) - task_creation_mode = db.Column( - db.Integer, default=TaskCreationMode.GRID.value, nullable=False + due_date = Column(DateTime) + imagery = Column(String) + josm_preset = Column(String) + id_presets = Column(ARRAY(String)) + extra_id_params = Column(String) + rapid_power_user = Column(Boolean, default=False) + last_updated = Column(DateTime, default=timestamp()) + progress_email_sent = Column(Boolean, default=False) + license_id = Column(Integer, ForeignKey("licenses.id", name="fk_licenses")) + geometry = Column(Geometry("MULTIPOLYGON", srid=4326), nullable=False) + centroid = Column(Geometry("POINT", srid=4326), nullable=False) + country = Column(ARRAY(String), default=[]) + task_creation_mode = Column( + Integer, default=TaskCreationMode.GRID.value, nullable=False ) - organisation_id = db.Column( - db.Integer, - db.ForeignKey("organisations.id", name="fk_organisations"), + organisation_id = Column( + Integer, + ForeignKey("organisations.id", name="fk_organisations"), index=True, ) # Tags - mapping_types = db.Column(ARRAY(db.Integer), index=True) + mapping_types = Column(ARRAY(Integer), index=True) # Editors - mapping_editors = db.Column( - ARRAY(db.Integer), + mapping_editors = Column( + ARRAY(Integer), default=[ Editors.ID.value, Editors.JOSM.value, @@ -184,8 +197,8 @@ class Project(db.Model): index=True, nullable=False, ) - validation_editors = db.Column( - ARRAY(db.Integer), + validation_editors = Column( + ARRAY(Integer), default=[ Editors.ID.value, Editors.JOSM.value, @@ -196,10 +209,10 @@ class Project(db.Model): ) # Stats - total_tasks = db.Column(db.Integer, nullable=False) - tasks_mapped = db.Column(db.Integer, default=0, nullable=False) - tasks_validated = db.Column(db.Integer, default=0, nullable=False) - tasks_bad_imagery = db.Column(db.Integer, default=0, nullable=False) + total_tasks = Column(Integer, nullable=False) + tasks_mapped = Column(Integer, default=0, nullable=False) + tasks_validated = Column(Integer, default=0, nullable=False) + tasks_bad_imagery = Column(Integer, default=0, nullable=False) # Total tasks are always >= 1 @hybrid_property @@ -215,31 +228,32 @@ def percent_validated(self): return self.tasks_validated * 100 // (self.total_tasks - self.tasks_bad_imagery) # Mapped Objects - tasks = db.relationship( + tasks = orm.relationship( Task, backref="projects", cascade="all, delete, delete-orphan", lazy="dynamic" ) - project_info = db.relationship(ProjectInfo, lazy="dynamic", cascade="all") - project_chat = db.relationship(ProjectChat, lazy="dynamic", cascade="all") - author = db.relationship(User) - allowed_users = db.relationship(User, secondary=project_allowed_users) - priority_areas = db.relationship( + project_info = orm.relationship(ProjectInfo, lazy="dynamic", cascade="all") + project_chat = orm.relationship(ProjectChat, lazy="dynamic", cascade="all") + author = orm.relationship(User) + allowed_users = orm.relationship(User, secondary=project_allowed_users) + priority_areas = orm.relationship( PriorityArea, secondary=project_priority_areas, cascade="all, delete-orphan", single_parent=True, ) - custom_editor = db.relationship( + custom_editor = orm.relationship( CustomEditor, cascade="all, delete-orphan", uselist=False ) - favorited = db.relationship(User, secondary=project_favorites, backref="favorites") - organisation = db.relationship(Organisation, backref="projects") - campaign = db.relationship( + favorited = orm.relationship(User, secondary=project_favorites, backref="favorites") + # organisation = orm.relationship(Organisation, backref="projects", lazy="joined") + organisation = orm.relationship(Organisation, backref="projects") + campaign = orm.relationship( Campaign, secondary=campaign_projects, backref="projects" ) - interests = db.relationship( + interests = orm.relationship( Interest, secondary=project_interests, backref="projects" ) - partnerships = db.relationship("ProjectPartnership", backref="project") + partnerships = orm.relationship("ProjectPartnership", backref="project") def create_draft_project(self, draft_project_dto: DraftProjectDTO): """ @@ -247,41 +261,76 @@ def create_draft_project(self, draft_project_dto: DraftProjectDTO): :param draft_project_dto: DTO containing draft project details :param aoi: Area of Interest for the project (eg boundary of project) """ - self.project_info.append( - ProjectInfo.create_from_name(draft_project_dto.project_name) - ) - self.organisation = draft_project_dto.organisation + organisation = dict(draft_project_dto.organisation) + organisation["id"] = organisation.pop("organisation_id") + self.organisation = Organisation(**organisation) + self.organisation_id = self.organisation.id self.status = ProjectStatus.DRAFT.value self.author_id = draft_project_dto.user_id + self.created = timestamp() self.last_updated = timestamp() - def set_project_aoi(self, draft_project_dto: DraftProjectDTO): + async def set_project_aoi(self, draft_project_dto: DraftProjectDTO, db: Database): """Sets the AOI for the supplied project""" aoi_geojson = geojson.loads(json.dumps(draft_project_dto.area_of_interest)) aoi_geometry = GridService.merge_to_multi_polygon(aoi_geojson, dissolve=True) valid_geojson = geojson.dumps(aoi_geometry) - self.geometry = ST_SetSRID(ST_GeomFromGeoJSON(valid_geojson), 4326) - self.centroid = ST_Centroid(self.geometry) + + query = """ + SELECT ST_AsText( + ST_SetSRID( + ST_GeomFromGeoJSON(:geojson), 4326 + ) + ) AS geometry_wkt; + """ + # Execute the query with the GeoJSON value passed in as a parameter + result = await db.fetch_one(query=query, values={"geojson": valid_geojson}) + self.geometry = result["geometry_wkt"] if result else None + + query = """ + SELECT ST_AsText(ST_Centroid(ST_SetSRID(ST_GeomFromGeoJSON(:geometry), 4326))) AS centroid + """ + + # Execute the query and pass the GeoJSON as a parameter + result = await db.fetch_one(query=query, values={"geometry": valid_geojson}) + self.centroid = result["centroid"] if result else None def set_default_changeset_comment(self): """Sets the default changeset comment""" - default_comment = current_app.config["DEFAULT_CHANGESET_COMMENT"] + default_comment = settings.DEFAULT_CHANGESET_COMMENT self.changeset_comment = ( f"{default_comment}-{self.id} {self.changeset_comment}" if self.changeset_comment is not None else f"{default_comment}-{self.id}" ) - self.save() def set_country_info(self): """Sets the default country based on centroid""" + if not self.centroid: + logger.debug("Skipping country lookup due to missing centroid") + return + + centroid_wkt = None + if isinstance(self.centroid, str): + centroid_wkt = self.centroid + + elif isinstance(self.centroid, bytes): + try: + centroid_geom = wkb.loads(self.centroid) + centroid_wkt = centroid_geom.wkt + except Exception as e: + logger.debug(f"Error converting EWKB to WKT: {e}", exc_info=True) + return + else: + centroid_wkt = self.centroid - centroid = to_shape(self.centroid) + centroid = WKTElement(centroid_wkt, srid=4326) + centroid = to_shape(centroid) lat, lng = (centroid.y, centroid.x) url = "{0}/reverse?format=jsonv2&lat={1}&lon={2}&accept-language=en".format( - current_app.config["OSM_NOMINATIM_SERVER_URL"], lat, lng + settings.OSM_NOMINATIM_SERVER_URL, lat, lng ) headers = { "User-Agent": ( @@ -303,35 +352,106 @@ def set_country_info(self): requests.exceptions.ConnectionError, requests.exceptions.HTTPError, ) as e: - current_app.logger.debug(e, exc_info=True) - - self.save() + logger.debug(e, exc_info=True) - def create(self): + async def create(self, project_name: str, db: Database): """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() + values = {} + for column in Project.__table__.columns: + # Get attribute value from the instance + attribute_value = getattr(self, column.name, None) + values[column.name] = attribute_value + + values.pop("id", None) + + project = await db.execute(Project.__table__.insert().values(**values)) + await db.execute( + ProjectInfo.__table__.insert().values( + project_id=project, locale="en", name=project_name + ) + ) + # Set the default changeset comment + default_comment = settings.DEFAULT_CHANGESET_COMMENT + self.changeset_comment = ( + f"{default_comment}-{project} {self.changeset_comment}" + if self.changeset_comment is not None + else f"{default_comment}-{project}" + ) + # Update the changeset comment in the database + await db.execute( + Project.__table__.update() + .where(Project.__table__.c.id == project) + .values(changeset_comment=self.changeset_comment) + ) + + for task in self.tasks: + await db.execute( + Task.__table__.insert().values( + id=task.id, + project_id=project, + x=task.x, + y=task.y, + zoom=task.zoom, + is_square=task.is_square, + task_status=TaskStatus.READY.value, + extra_properties=task.extra_properties, + geometry=task.geometry, + ) + ) + + return project - def save(self): + async def save(self, db: Database): """Save changes to db""" - db.session.commit() + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + await db.execute( + Project.__table__.update().where(Project.id == self.id).values(**columns) + ) + + for task in self.tasks: + await db.execute( + Task.__table__.insert().values( + id=task.id, + project_id=self.id, + x=task.x, + y=task.y, + zoom=task.zoom, + is_square=task.is_square, + task_status=TaskStatus.READY.value, + extra_properties=task.extra_properties, + geometry=task.geometry, + ) + ) @staticmethod - def clone(project_id: int, author_id: int): - """Clone project""" + async def clone(project_id: int, author_id: int, db: Database): + """Clone a project using encode databases and raw SQL.""" + # Fetch the original project data + orig_query = "SELECT * FROM projects WHERE id = :project_id" + orig = await db.fetch_one(orig_query, {"project_id": project_id}) - orig = db.session.get(Project, project_id) - if orig is None: + if not orig: raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) - # Transform into dictionary. - orig_metadata = orig.__dict__.copy() + raw = orig.changeset_comment or "" - # Remove unneeded data. - items_to_remove = ["_sa_instance_state", "id", "allowed_users"] - [orig_metadata.pop(i, None) for i in items_to_remove] + default_comment = settings.DEFAULT_CHANGESET_COMMENT + if default_comment: + old_prefix = f"{default_comment}-{project_id}" + leftover = raw.replace(old_prefix, "").strip() + else: + leftover = raw - # Remove clone from session so we can reinsert it as a new object + orig_metadata = dict(orig) + items_to_remove = ["id", "allowed_users"] + for item in items_to_remove: + orig_metadata.pop(item, None) + + orig_metadata["changeset_comment"] = leftover or None + + # Update metadata for the new project orig_metadata.update( { "total_tasks": 0, @@ -345,64 +465,145 @@ def clone(project_id: int, author_id: int): } ) - new_proj = Project(**orig_metadata) - db.session.add(new_proj) + # Construct the INSERT query for the new project + columns = ", ".join(orig_metadata.keys()) + values = ", ".join([f":{key}" for key in orig_metadata.keys()]) + insert_project_query = ( + f"INSERT INTO projects ({columns}) VALUES ({values}) RETURNING id" + ) + new_project_id = await db.execute(insert_project_query, orig_metadata) - proj_info = [] - for info in orig.project_info.all(): - info_data = info.__dict__.copy() - info_data.pop("_sa_instance_state") - info_data.update( - {"project_id": new_proj.id, "project_id_str": str(new_proj.id)} - ) - proj_info.append(ProjectInfo(**info_data)) + # Clone project_info data + project_info_query = "SELECT * FROM project_info WHERE project_id = :project_id" + project_info_records = await db.fetch_all( + project_info_query, {"project_id": project_id} + ) - new_proj.project_info = proj_info + for info in project_info_records: + info_data = dict(info) + info_data.pop("id", None) + info_data.update({"project_id": new_project_id}) + columns_info = ", ".join(info_data.keys()) + values_info = ", ".join([f":{key}" for key in info_data.keys()]) + insert_info_query = ( + f"INSERT INTO project_info ({columns_info}) VALUES ({values_info})" + ) + await db.execute(insert_info_query, info_data) + + # Clone teams data + teams_query = "SELECT * FROM project_teams WHERE project_id = :project_id" + team_records = await db.fetch_all(teams_query, {"project_id": project_id}) + + for team in team_records: + team_data = dict(team) + team_data.pop("id", None) + team_data.update({"project_id": new_project_id}) + columns_team = ", ".join(team_data.keys()) + values_team = ", ".join([f":{key}" for key in team_data.keys()]) + insert_team_query = ( + f"INSERT INTO project_teams ({columns_team}) VALUES ({values_team})" + ) + await db.execute(insert_team_query, team_data) - # Replace changeset comment. - default_comment = current_app.config["DEFAULT_CHANGESET_COMMENT"] + # Clone campaigns associated with the original project + campaign_query = ( + "SELECT campaign_id FROM campaign_projects WHERE project_id = :project_id" + ) + campaign_ids = await db.fetch_all(campaign_query, {"project_id": project_id}) + + for campaign in campaign_ids: + clone_campaign_query = """ + INSERT INTO campaign_projects (campaign_id, project_id) + VALUES (:campaign_id, :new_project_id) + """ + await db.execute( + clone_campaign_query, + { + "campaign_id": campaign["campaign_id"], + "new_project_id": new_project_id, + }, + ) - if default_comment is not None: - orig_changeset = f"{default_comment}-{orig.id}" # Preserve space - new_proj.changeset_comment = orig.changeset_comment.replace( - orig_changeset, "" - ).strip() + # Clone interests associated with the original project + interest_query = ( + "SELECT interest_id FROM project_interests WHERE project_id = :project_id" + ) + interest_ids = await db.fetch_all(interest_query, {"project_id": project_id}) + + for interest in interest_ids: + clone_interest_query = """ + INSERT INTO project_interests (interest_id, project_id) + VALUES (:interest_id, :new_project_id) + """ + await db.execute( + clone_interest_query, + { + "interest_id": interest["interest_id"], + "new_project_id": new_project_id, + }, + ) - # Populate teams, interests and campaigns - teams = [] - for team in orig.teams: - team_data = team.__dict__.copy() - team_data.pop("_sa_instance_state") - team_data.update({"project_id": new_proj.id}) - teams.append(ProjectTeams(**team_data)) - new_proj.teams = teams + # Clone CustomEditor associated with the original project + custom_editor_query = """ + SELECT name, description, url FROM project_custom_editors WHERE project_id = :project_id + """ + custom_editor = await db.fetch_one( + custom_editor_query, {"project_id": project_id} + ) - for field in ["interests", "campaign"]: - value = getattr(orig, field) - setattr(new_proj, field, value) - if orig.custom_editor: - new_proj.custom_editor = orig.custom_editor.clone_to_project(new_proj.id) + if custom_editor: + clone_custom_editor_query = """ + INSERT INTO project_custom_editors (project_id, name, description, url) + VALUES (:new_project_id, :name, :description, :url) + """ + await db.execute( + clone_custom_editor_query, + { + "new_project_id": new_project_id, + "name": custom_editor["name"], + "description": custom_editor["description"], + "url": custom_editor["url"], + }, + ) - return new_proj + # Return the new project data + new_project_query = "SELECT * FROM projects WHERE id = :new_project_id" + new_project = await db.fetch_one( + new_project_query, {"new_project_id": new_project_id} + ) + return Project(**new_project) @staticmethod - def get(project_id: int) -> Optional["Project"]: + async def get(project_id: int, db: Database) -> Optional["Project"]: """ Gets specified project :param project_id: project ID in scope + :param db: Instance of `databases.Database` for querying :return: Project if found otherwise None """ - return db.session.get( - Project, - project_id, - options=[ + # Construct the SQLAlchemy select statement + query = ( + select(Project) + .where(Project.id == project_id) + .options( orm.noload(Project.tasks), orm.noload(Project.messages), orm.noload(Project.project_chat), - ], + ) ) - def update(self, project_dto: ProjectDTO): + # Execute the query using the `fetch_one` method of `db` + result = await db.fetch_one(query) + + # If a result is found, map it back to the Project ORM class + # (If `Project` is a Core table, you can directly return `result`) + if result: + project = Project(**result) + return project + + return None + + async def update(self, project_dto: ProjectDTO, db: Database): """Updates project from DTO""" self.status = ProjectStatus[project_dto.project_status].value self.priority = ProjectPriority[project_dto.project_priority].value @@ -416,7 +617,9 @@ def update(self, project_dto: ProjectDTO): self.private = project_dto.private self.difficulty = ProjectDifficulty[project_dto.difficulty.upper()].value self.changeset_comment = project_dto.changeset_comment - self.due_date = project_dto.due_date + self.due_date = ( + project_dto.due_date.replace(tzinfo=None) if project_dto.due_date else None + ) self.imagery = project_dto.imagery self.josm_preset = project_dto.josm_preset self.id_presets = project_dto.id_presets @@ -435,13 +638,29 @@ def update(self, project_dto: ProjectDTO): self.osmcha_filter_id = None if project_dto.organisation: - org = Organisation.get(project_dto.organisation) - if org is None: + organisation_query = "SELECT * FROM organisations WHERE id = :id" + organization = await db.fetch_one( + organisation_query, values={"id": project_dto.organisation} + ) + + if organization is None: raise NotFound( sub_code="ORGANISATION_NOT_FOUND", organisation_id=project_dto.organisation, ) - self.organisation = org + + update_organisation_query = """ + UPDATE projects + SET organisation_id = :organisation_id + WHERE id = :project_id + """ + await db.execute( + update_organisation_query, + values={ + "organisation_id": project_dto.organisation, + "project_id": project_dto.project_id, + }, + ) # Cast MappingType strings to int array type_array = [] @@ -468,57 +687,107 @@ def update(self, project_dto: ProjectDTO): self.allowed_users.append(user) # Update teams and projects relationship. - self.teams = [] + await db.execute(delete(ProjectTeams).where(ProjectTeams.project_id == self.id)) if hasattr(project_dto, "project_teams") and project_dto.project_teams: for team_dto in project_dto.project_teams: - team = Team.get(team_dto.team_id) - + team = await Team.get(team_dto.team_id, db) if team is None: raise NotFound(sub_code="TEAM_NOT_FOUND", team_id=team_dto.team_id) - role = TeamRoles[team_dto.role].value - project_team = ProjectTeams(project=self, team=team, role=role) - db.session.add(project_team) + project_team = ProjectTeams( + project_id=self.id, team_id=team.id, role=role + ) + await project_team.create(db) # Set Project Info for all returned locales for dto in project_dto.project_info_locales: - project_info = self.project_info.filter_by(locale=dto.locale).one_or_none() + project_info = await db.fetch_one( + select(ProjectInfo).where( + ProjectInfo.project_id == self.id, ProjectInfo.locale == dto.locale + ) + ) if project_info is None: - new_info = ProjectInfo.create_from_dto( - dto + await ProjectInfo.create_from_dto( + dto, self.id, db ) # Can't find info so must be new locale - self.project_info.append(new_info) else: - project_info.update_from_dto(dto) + await ProjectInfo.update_from_dto(ProjectInfo(**project_info), dto, db) - self.priority_areas = [] # Always clear Priority Area prior to updating + # Always clear Priority Area prior to updating + await Project.clear_existing_priority_areas(db, self.id) if project_dto.priority_areas: for priority_area in project_dto.priority_areas: - pa = PriorityArea.from_dict(priority_area) - self.priority_areas.append(pa) + pa = await PriorityArea.from_dict(priority_area, db) + # Link project and priority area in the database + if pa and pa.id: + link_query = """ + INSERT INTO project_priority_areas (project_id, priority_area_id) + VALUES (:project_id, :priority_area_id) + """ + await db.execute( + query=link_query, + values={"project_id": self.id, "priority_area_id": pa.id}, + ) if project_dto.custom_editor: - if not self.custom_editor: - new_editor = CustomEditor.create_from_dto( - self.id, project_dto.custom_editor + custom_editor = await CustomEditor.get_by_project_id(self.id, db) + if not custom_editor: + await CustomEditor.create_from_dto( + self.id, project_dto.custom_editor, db ) - self.custom_editor = new_editor else: - self.custom_editor.update_editor(project_dto.custom_editor) + await CustomEditor.update_editor(self.id, project_dto.custom_editor, db) else: - if self.custom_editor: - self.custom_editor.delete() + await CustomEditor.delete(self.id, db) # handle campaign update try: new_ids = [c.id for c in project_dto.campaigns] - new_ids.sort() except TypeError: new_ids = [] - current_ids = [c.id for c in self.campaign] - current_ids.sort() - if new_ids != current_ids: - self.campaign = Campaign.query.filter(Campaign.id.in_(new_ids)).all() + + query = """ + SELECT campaign_id + FROM campaign_projects + WHERE project_id = :project_id + """ + campaign_results = await db.fetch_all( + query, values={"project_id": project_dto.project_id} + ) + current_ids = [c.campaign_id for c in campaign_results] + + new_set = set(new_ids) + current_set = set(current_ids) + + if new_set != current_set: + to_add = new_set - current_set + to_remove = current_set - new_set + if to_remove: + await db.execute( + """ + DELETE FROM campaign_projects + WHERE project_id = :project_id + AND campaign_id = ANY(:to_remove) + """, + values={ + "project_id": project_dto.project_id, + "to_remove": list(to_remove), + }, + ) + + if to_add: + insert_query = """ + INSERT INTO campaign_projects (project_id, campaign_id) + VALUES (:project_id, :campaign_id) + """ + for campaign_id in to_add: + await db.execute( + insert_query, + values={ + "project_id": project_dto.project_id, + "campaign_id": campaign_id, + }, + ) if project_dto.mapping_permission: self.mapping_permission = MappingPermission[ @@ -532,96 +801,281 @@ def update(self, project_dto: ProjectDTO): # handle interests update try: - new_ids = [c.id for c in project_dto.interests] - new_ids.sort() + new_interest_ids = [i.id for i in project_dto.interests] except TypeError: - new_ids = [] - current_ids = [c.id for c in self.interests] - current_ids.sort() - if new_ids != current_ids: - self.interests = Interest.query.filter(Interest.id.in_(new_ids)).all() + new_interest_ids = [] + + interest_query = """ + SELECT interest_id + FROM project_interests + WHERE project_id = :project_id + """ + interest_results = await db.fetch_all( + interest_query, values={"project_id": project_dto.project_id} + ) + current_interest_ids = [i.interest_id for i in interest_results] + + new_interest_set = set(new_interest_ids) + current_interest_set = set(current_interest_ids) + + if new_interest_set != current_interest_set: + to_add_interests = new_interest_set - current_interest_set + to_remove_interests = current_interest_set - new_interest_set + + if to_remove_interests: + await db.execute( + """ + DELETE FROM project_interests + WHERE project_id = :project_id + AND interest_id = ANY(:to_remove) + """, + values={ + "project_id": project_dto.project_id, + "to_remove": list(to_remove_interests), + }, + ) + + if to_add_interests: + insert_interest_query = """ + INSERT INTO project_interests (project_id, interest_id) + VALUES (:project_id, :interest_id) + """ + for interest_id in to_add_interests: + await db.execute( + insert_interest_query, + values={ + "project_id": project_dto.project_id, + "interest_id": interest_id, + }, + ) # try to update country info if that information is not present if not self.country: self.set_country_info() - db.session.commit() + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + columns.pop("geometry", None) + columns.pop("centroid", None) + columns.pop("id", None) + columns.pop("organisation_id", None) + # Update the project in the database + await db.execute( + self.__table__.update().where(Project.id == self.id).values(**columns) + ) + + async def delete(self, db: Database): + """Deletes the current project and related records from the database using raw SQL.""" + # List of tables to delete from, in the order required to satisfy foreign key constraints + related_tables = [ + "project_favorites", + "campaign_projects", + "project_custom_editors", + "project_interests", + "project_priority_areas", + "project_allowed_users", + "project_teams", + "task_invalidation_history", + "task_history", + "tasks", + "project_info", + "project_chat", + "project_partnerships_history", + "project_partnerships", + ] + + # Start a transaction to ensure atomic deletion + async with db.transaction(): + # Loop through each table and execute the delete query + for table in related_tables: + await db.execute( + f"DELETE FROM {table} WHERE project_id = :project_id", + {"project_id": self.id}, + ) - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + # Finally, delete the project itself + await db.execute( + "DELETE FROM projects WHERE id = :project_id", {"project_id": self.id} + ) @staticmethod - def exists(project_id): - query = Project.query.filter(Project.id == project_id).exists() + async def exists(project_id: int, db: Database) -> bool: + query = """ + SELECT 1 + FROM projects + WHERE id = :project_id + """ - return db.session.query(literal(True)).filter(query).scalar() + # Execute the query + result = await db.fetch_one(query=query, values={"project_id": project_id}) - def is_favorited(self, user_id: int) -> bool: - user = db.session.get(User, user_id) - if user not in self.favorited: - return False + if result is None: + raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) return True - def favorite(self, user_id: int): - user = db.session.get(User, user_id) - self.favorited.append(user) - db.session.commit() + @staticmethod + async def is_favorited(project_id: int, user_id: int, db: Database) -> bool: + query = """ + SELECT 1 + FROM project_favorites + WHERE user_id = :user_id + AND project_id = :project_id + LIMIT 1 + """ + + result = await db.fetch_one( + query, values={"user_id": user_id, "project_id": project_id} + ) + return result is not None - def unfavorite(self, user_id: int): - user = db.session.get(User, user_id) - if user not in self.favorited: - raise ValueError("NotFeatured- Project not been favorited by user") - self.favorited.remove(user) - db.session.commit() + @staticmethod + async def favorite(project_id: int, user_id: int, db: Database): + check_query = """ + SELECT 1 FROM project_favorites WHERE project_id = :project_id AND user_id = :user_id + """ + exists = await db.fetch_one( + check_query, {"project_id": project_id, "user_id": user_id} + ) + + if not exists: + insert_query = """ + INSERT INTO project_favorites (project_id, user_id) + VALUES (:project_id, :user_id) + """ + await db.execute( + insert_query, {"project_id": project_id, "user_id": user_id} + ) + + @staticmethod + async def unfavorite(project_id: int, user_id: int, db: Database): + check_query = """ + SELECT 1 FROM project_favorites + WHERE project_id = :project_id AND user_id = :user_id + """ + exists = await db.fetch_one( + check_query, {"project_id": project_id, "user_id": user_id} + ) + + if not exists: + raise ValueError("NotFeatured - Project has not been favorited by user") + + delete_query = """ + DELETE FROM project_favorites + WHERE project_id = :project_id AND user_id = :user_id + """ + await db.execute(delete_query, {"project_id": project_id, "user_id": user_id}) - def set_as_featured(self): + async def set_as_featured(self, db: Database): + """ + Sets the project as featured. + :param db: Instance of `databases.Database` for querying + """ if self.featured is True: raise ValueError("AlreadyFeatured- Project is already featured") - self.featured = True - db.session.commit() - def unset_as_featured(self): + query = update(Project).where(Project.id == self.id).values(featured=True) + + # Execute the update query using the async `db.execute` + await db.execute(query) + + async def unset_as_featured(self, db: Database): + """ + Unsets the project as featured. + :param db: Instance of `databases.Database` for querying + """ + # Check if the project is already not featured if self.featured is False: - raise ValueError("NotFeatured- Project is not featured") - self.featured = False - db.session.commit() - - def can_be_deleted(self) -> bool: - """Projects can be deleted if they have no mapped work""" - task_count = self.tasks.filter( - Task.task_status != TaskStatus.READY.value - ).count() + raise ValueError("NotFeatured - Project is not featured") + + query = update(Project).where(Project.id == self.id).values(featured=False) + + # Execute the update query using the async `db.execute` + await db.execute(query) + + async def can_be_deleted(self, db: Database) -> bool: + """Projects can be deleted if they have no mapped work.""" + # Build a query to count tasks associated with the project + query = ( + select(func.count()) + .select_from(Task) + .where( + Task.project_id + == self.id, # Assuming `self.id` refers to the project instance ID + Task.task_status != TaskStatus.READY.value, + ) + ) + + # Execute the query + task_count = await db.fetch_val(query) if task_count == 0: return True else: return False @staticmethod - def get_projects_for_admin( - admin_id: int, preferred_locale: str, search_dto: ProjectSearchDTO + async def get_projects_for_admin( + admin_id: int, preferred_locale: str, search_dto: ProjectSearchDTO, db: Database ) -> PMDashboardDTO: - """Get projects for admin""" - query = Project.query.filter(Project.author_id == admin_id) - # Do Filtering Here + """Get all projects for provided admin.""" + + query = """ + SELECT + p.id AS id, + p.difficulty, + p.priority, + p.default_locale, + ST_AsGeoJSON(p.centroid) AS centroid, + p.organisation_id, + p.tasks_bad_imagery, + p.tasks_mapped, + p.tasks_validated, + p.status, + p.mapping_types, + p.total_tasks, + p.last_updated, + p.due_date, + p.country, + p.changeset_comment, + p.created, + p.osmcha_filter_id, + p.mapping_permission, + p.validation_permission, + p.enforce_random_task_selection, + p.private, + p.license_id, + p.id_presets, + p.extra_id_params, + p.rapid_power_user, + p.imagery, + p.mapping_editors, + p.validation_editors, + u.username AS author, + o.name AS organisation_name, + o.slug AS organisation_slug, + o.logo AS organisation_logo, + ARRAY(SELECT user_id FROM project_allowed_users WHERE project_id = p.id) AS allowed_users + FROM projects p + LEFT JOIN organisations o ON o.id = p.organisation_id + LEFT JOIN users u ON u.id = p.author_id + WHERE p.author_id = :admin_id + """ + + params = {"admin_id": admin_id} if search_dto.order_by: + query += f" ORDER BY p.{search_dto.order_by} " if search_dto.order_by_type == "DESC": - query = query.order_by(desc(search_dto.order_by)) - else: - query = query.order_by(search_dto.order_by) - - admins_projects = query.all() - - if admins_projects is None: - raise NotFound(sub_code="PROJECTS_NOT_FOUND") + query += "DESC" + # Execute query + rows = await db.fetch_all(query, params) + # Process results admin_projects_dto = PMDashboardDTO() - for project in admins_projects: - pm_project = project.get_project_summary(preferred_locale) - project_status = ProjectStatus(project.status) + for row in rows: + pm_project = await Project.get_project_summary(row, preferred_locale, db) + project_status = ProjectStatus(row["status"]) if project_status == ProjectStatus.DRAFT: admin_projects_dto.draft_projects.append(pm_project) @@ -630,107 +1084,154 @@ def get_projects_for_admin( elif project_status == ProjectStatus.ARCHIVED: admin_projects_dto.archived_projects.append(pm_project) else: - current_app.logger.error(f"Unexpected state project {project.id}") + raise HTTPException( + status_code=500, detail=f"Unexpected state project {row['id']}" + ) return admin_projects_dto - def get_project_user_stats(self, user_id: int) -> ProjectUserStatsDTO: - """Compute project specific stats for a given user""" + @staticmethod + async def get_project_user_stats( + project_id: int, user_id: int, db: Database + ) -> ProjectUserStatsDTO: + """Compute project-specific stats for a given user""" stats_dto = ProjectUserStatsDTO() - stats_dto.time_spent_mapping = 0 - stats_dto.time_spent_validating = 0 - stats_dto.total_time_spent = 0 - total_mapping_time = ( - db.session.query( - func.sum( - cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) - ) - ) - .filter( - or_( - TaskHistory.action == "LOCKED_FOR_MAPPING", - TaskHistory.action == "AUTO_UNLOCKED_FOR_MAPPING", - ) - ) - .filter(TaskHistory.user_id == user_id) - .filter(TaskHistory.project_id == self.id) + total_mapping_query = """ + SELECT + SUM(TO_TIMESTAMP(action_text, 'HH24:MI:SS')::TIME - '00:00:00'::TIME) AS total_time + FROM task_history + WHERE action IN ('LOCKED_FOR_MAPPING', 'AUTO_UNLOCKED_FOR_MAPPING') + AND project_id = :project_id + AND user_id = :user_id + """ + total_mapping_result = await db.fetch_one( + total_mapping_query, {"project_id": project_id, "user_id": user_id} ) - for time in total_mapping_time: - total_mapping_time = time[0] - if total_mapping_time: - stats_dto.time_spent_mapping = total_mapping_time.total_seconds() - stats_dto.total_time_spent += stats_dto.time_spent_mapping - query = ( - TaskHistory.query.with_entities( - func.date_trunc("minute", TaskHistory.action_date).label("trn"), - func.max(TaskHistory.action_text).label("tm"), - ) - .filter(TaskHistory.user_id == user_id) - .filter(TaskHistory.project_id == self.id) - .filter(TaskHistory.action == "LOCKED_FOR_VALIDATION") - .group_by("trn") - .subquery() + total_mapping_time = ( + total_mapping_result["total_time"].total_seconds() + if total_mapping_result and total_mapping_result["total_time"] + else 0 + ) + stats_dto.time_spent_mapping = total_mapping_time + stats_dto.total_time_spent += total_mapping_time + + total_validation_query = """ + SELECT + SUM(TO_TIMESTAMP(action_text, 'HH24:MI:SS')::TIME - '00:00:00'::TIME) AS total_time + FROM task_history + WHERE action IN ('LOCKED_FOR_VALIDATION', 'AUTO_UNLOCKED_FOR_VALIDATION') + AND project_id = :project_id + AND user_id = :user_id + """ + total_validation_result = await db.fetch_one( + total_validation_query, {"project_id": project_id, "user_id": user_id} ) - total_validation_time = db.session.query( - func.sum(cast(func.to_timestamp(query.c.tm, "HH24:MI:SS"), Time)) - ).all() - - for time in total_validation_time: - total_validation_time = time[0] - if total_validation_time: - stats_dto.time_spent_validating = total_validation_time.total_seconds() - stats_dto.total_time_spent += stats_dto.time_spent_validating + total_validation_time = ( + total_validation_result["total_time"].total_seconds() + if total_validation_result and total_validation_result["total_time"] + else 0 + ) + stats_dto.time_spent_validating = total_validation_time + stats_dto.total_time_spent += total_validation_time return stats_dto - def get_project_stats(self) -> ProjectStatsDTO: - """Create Project Stats model for postgis project object""" + @staticmethod + async def get_project_stats(project_id: int, database: Database) -> ProjectStatsDTO: + """Create Project Stats model for postgis project object.""" project_stats = ProjectStatsDTO() - project_stats.project_id = self.id - project_stats.area = ( - db.session.query(func.ST_Area(Project.geometry, True)) - .where(Project.id == self.id) - .first()[0] - / 1000000 + project_stats.project_id = project_id + project_query = """ + SELECT + ST_Area(geometry, TRUE) / 1000000 AS area, + ST_AsGeoJSON(centroid) AS centroid_geojson, + tasks_mapped, + tasks_validated, + total_tasks, + tasks_bad_imagery + FROM projects + WHERE id = :project_id + """ + + result = await database.fetch_one( + project_query, values={"project_id": project_id} + ) + + project_stats.area = result["area"] + project_stats.aoi_centroid = ( + geojson.loads(result["centroid_geojson"]) + if result["centroid_geojson"] + else None + ) + tasks_mapped = result["tasks_mapped"] + tasks_validated = result["tasks_validated"] + total_tasks = result["total_tasks"] + tasks_bad_imagery = result["tasks_bad_imagery"] + + # Calculate task percentages + project_stats.total_tasks = total_tasks + + project_stats.percent_mapped = Project.calculate_tasks_percent( + "mapped", tasks_mapped, tasks_validated, total_tasks, tasks_bad_imagery + ) + project_stats.percent_validated = Project.calculate_tasks_percent( + "validated", tasks_mapped, tasks_validated, total_tasks, tasks_bad_imagery + ) + project_stats.percent_bad_imagery = Project.calculate_tasks_percent( + "bad_imagery", tasks_mapped, tasks_validated, total_tasks, tasks_bad_imagery ) + # Query for total mappers + total_mappers_query = """ + SELECT COUNT(*) + FROM users + WHERE :project_id = ANY(projects_mapped) + """ + total_mappers_result = await database.fetch_one( + total_mappers_query, values={"project_id": project_id} + ) project_stats.total_mappers = ( - db.session.query(User).filter(User.projects_mapped.any(self.id)).count() + total_mappers_result[0] if total_mappers_result else 0 + ) + + # Query for total comments + total_comments_query = """ + SELECT COUNT(*) + FROM project_chat + WHERE project_id = :project_id + """ + total_comments_result = await database.fetch_one( + total_comments_query, values={"project_id": project_id} ) - project_stats.total_tasks = self.total_tasks project_stats.total_comments = ( - db.session.query(ProjectChat) - .filter(ProjectChat.project_id == self.id) - .count() + total_comments_result[0] if total_comments_result else 0 ) - project_stats.percent_mapped = self.calculate_tasks_percent("mapped") - project_stats.percent_validated = self.calculate_tasks_percent("validated") - project_stats.percent_bad_imagery = self.calculate_tasks_percent("bad_imagery") - centroid_geojson = db.session.scalar(self.centroid.ST_AsGeoJSON()) - project_stats.aoi_centroid = geojson.loads(centroid_geojson) + + # Initialize time stats project_stats.total_time_spent = 0 project_stats.total_mapping_time = 0 project_stats.total_validation_time = 0 project_stats.average_mapping_time = 0 project_stats.average_validation_time = 0 + # Query total mapping time and tasks + total_mapping_query = """ + SELECT + SUM(TO_TIMESTAMP(action_text, 'HH24:MI:SS')::TIME - '00:00:00'::TIME) AS total_time, + COUNT(action) AS total_tasks + FROM task_history + WHERE action IN ('LOCKED_FOR_MAPPING', 'AUTO_UNLOCKED_FOR_MAPPING') + AND project_id = :project_id + """ + total_mapping_result = await database.fetch_one( + total_mapping_query, values={"project_id": project_id} + ) total_mapping_time, total_mapping_tasks = ( - db.session.query( - func.sum( - cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) - ), - func.count(TaskHistory.action), - ) - .filter( - or_( - TaskHistory.action == "LOCKED_FOR_MAPPING", - TaskHistory.action == "AUTO_UNLOCKED_FOR_MAPPING", - ) - ) - .filter(TaskHistory.project_id == self.id) - .one() + (total_mapping_result["total_time"], total_mapping_result["total_tasks"]) + if total_mapping_result + else (0, 0) ) if total_mapping_tasks > 0: @@ -741,23 +1242,30 @@ def get_project_stats(self) -> ProjectStatsDTO: ) project_stats.total_time_spent += total_mapping_time + # Query total validation time and tasks + total_validation_query = """ + SELECT + SUM(TO_TIMESTAMP(action_text, 'HH24:MI:SS')::TIME - '00:00:00'::TIME) AS total_time, + COUNT(action) AS total_tasks + FROM task_history + WHERE action IN ('LOCKED_FOR_VALIDATION', 'AUTO_UNLOCKED_FOR_VALIDATION') + AND project_id = :project_id + """ + total_validation_result = await database.fetch_one( + total_validation_query, values={"project_id": project_id} + ) + + # Safely unpack the results, or default to (0, 0) if the query returns no results total_validation_time, total_validation_tasks = ( - db.session.query( - func.sum( - cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) - ), - func.count(TaskHistory.action), + ( + total_validation_result["total_time"], + total_validation_result["total_tasks"], ) - .filter( - or_( - TaskHistory.action == "LOCKED_FOR_VALIDATION", - TaskHistory.action == "AUTO_UNLOCKED_FOR_VALIDATION", - ) - ) - .filter(TaskHistory.project_id == self.id) - .one() + if total_validation_result + else (0, 0) ) + # If there are validation tasks, convert the time to total seconds and update project stats if total_validation_tasks > 0: total_validation_time = total_validation_time.total_seconds() project_stats.total_validation_time = total_validation_time @@ -766,214 +1274,326 @@ def get_project_stats(self) -> ProjectStatsDTO: ) project_stats.total_time_spent += total_validation_time - actions = [] - if project_stats.average_mapping_time <= 0: - actions.append(TaskStatus.LOCKED_FOR_MAPPING.name) - if project_stats.average_validation_time <= 0: - actions.append(TaskStatus.LOCKED_FOR_VALIDATION.name) - - zoom_levels = [] - # Check that averages are non-zero. - if len(actions) != 0: - zoom_levels = ( - Task.query.with_entities(Task.zoom.distinct()) - .filter(Task.project_id == self.id) - .all() + # TODO: Understand the functionality of subquery used and incorporate this part. + + # actions = [] + # if project_stats.average_mapping_time <= 0: + # actions.append(TaskStatus.LOCKED_FOR_MAPPING.name) + # if project_stats.average_validation_time <= 0: + # actions.append(TaskStatus.LOCKED_FOR_VALIDATION.name) + + # zoom_levels = [] + # if actions: + # # Query for distinct zoom levels + # zoom_levels_query = """ + # SELECT DISTINCT zoom + # FROM tasks + # WHERE project_id = :project_id + # """ + # zoom_levels_result = await database.fetch_all(zoom_levels_query, values={"project_id": project_id}) + # zoom_levels = [row['zoom'] for row in zoom_levels_result] + + # is_square = None not in zoom_levels + + # subquery = f""" + # SELECT + # t.zoom, + # th.action, + # EXTRACT(EPOCH FROM TO_TIMESTAMP(th.action_text, 'HH24:MI:SS')) AS ts + # FROM task_history th + # JOIN tasks t ON th.task_id = t.id + # WHERE th.action IN :actions + # AND th.project_id = :project_id + # AND t.is_square = :is_square + # AND (t.zoom IN :zoom_levels OR :is_square IS FALSE) + # """ + # subquery_params = { + # "project_id": project_id, + # "actions": tuple(actions), + # "is_square": is_square, + # "zoom_levels": tuple(zoom_levels) if zoom_levels else (None,) + # } + # subquery_result = await database.fetch_all(subquery, values=subquery_params) + + # # Query for average mapping time + # if project_stats.average_mapping_time <= 0: + # mapping_avg_query = """ + # SELECT zoom, AVG(ts) AS avg + # FROM ( + # SELECT zoom, ts + # FROM subquery_result + # WHERE action = 'LOCKED_FOR_MAPPING' + # ) AS mapping_times + # GROUP BY zoom + # """ + # mapping_avg_result = await database.fetch_all(mapping_avg_query) + # if mapping_avg_result: + # total_mapping_time = sum(row['avg'].total_seconds() for row in mapping_avg_result) + # mapping_time = total_mapping_time / len(mapping_avg_result) + # project_stats.average_mapping_time = mapping_time + + # # Query for average validation time + # if project_stats.average_validation_time <= 0: + # validation_avg_query = """ + # SELECT zoom, AVG(ts) AS avg + # FROM ( + # SELECT zoom, ts + # FROM subquery_result + # WHERE action = 'LOCKED_FOR_VALIDATION' + # ) AS validation_times + # GROUP BY zoom + # """ + # validation_avg_result = await database.fetch_all(validation_avg_query) + # if validation_avg_result: + # total_validation_time = sum(row['avg'].total_seconds() for row in validation_avg_result) + # validation_time = total_validation_time / len(validation_avg_result) + # project_stats.average_validation_time = validation_time + + # Calculate time to finish mapping and validation + project_stats.time_to_finish_mapping = ( + total_tasks - (tasks_mapped + tasks_bad_imagery + tasks_validated) + ) * project_stats.average_mapping_time + project_stats.time_to_finish_validating = ( + total_tasks - (tasks_validated + tasks_bad_imagery) + ) * project_stats.average_validation_time + + return project_stats + + @staticmethod + async def get_project_summary( + project_row, preferred_locale: str, db: Database, calculate_completion=True + ) -> ProjectSummary: + """Create Project Summary model for a project.""" + + project_id = project_row["id"] + + # Mapping editors + if project_row.mapping_editors: + mapping_editors = ( + [ + Editors(mapping_editor).name + for mapping_editor in project_row.mapping_editors + ] + if project_row["mapping_editors"] + else [] ) - zoom_levels = [z[0] for z in zoom_levels] - - # Validate project has arbitrary tasks. - is_square = True - if None in zoom_levels: - is_square = False - sq = ( - TaskHistory.query.with_entities( - Task.zoom, - TaskHistory.action, - ( - cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) - ).label("ts"), + # Validation editors + if project_row.validation_editors: + validation_editors = ( + [ + Editors(validation_editor).name + for validation_editor in project_row["validation_editors"] + ] + if project_row["validation_editors"] + else [] ) - .filter(Task.is_square == is_square) - .filter(TaskHistory.project_id == Task.project_id) - .filter(TaskHistory.task_id == Task.id) - .filter(TaskHistory.action.in_(actions)) + summary = ProjectSummary( + project_id=project_id, + mapping_editors=mapping_editors, + validation_editors=validation_editors, ) - if is_square is True: - sq = sq.filter(Task.zoom.in_(zoom_levels)) - - sq = sq.subquery() - nz = ( - db.session.query(sq.c.zoom, sq.c.action, sq.c.ts) - .filter(sq.c.ts > datetime.time(0)) - .limit(10000) - .subquery() + # Set priority + priority_map = {0: "URGENT", 1: "HIGH", 2: "MEDIUM"} + summary.priority = priority_map.get(project_row["priority"], "LOW") + + summary.author = project_row.author + + # Set other fields directly from project_row + summary.default_locale = project_row.default_locale + summary.country_tag = project_row.country + summary.changeset_comment = project_row.changeset_comment + summary.due_date = project_row.due_date + summary.created = project_row.created + summary.last_updated = project_row.last_updated + summary.osmcha_filter_id = project_row.osmcha_filter_id + summary.difficulty = ProjectDifficulty(project_row["difficulty"]).name + summary.mapping_permission = MappingPermission( + project_row["mapping_permission"] + ).name + summary.validation_permission = ValidationPermission( + project_row["validation_permission"] + ).name + summary.random_task_selection_enforced = ( + project_row.enforce_random_task_selection ) + summary.private = project_row.private + summary.license_id = project_row.license_id + summary.status = ProjectStatus(project_row["status"]).name + summary.id_presets = project_row.id_presets + summary.extra_id_params = project_row.extra_id_params + summary.rapid_power_user = project_row.rapid_power_user + summary.imagery = project_row.imagery + + # Handle organisation details if available + if project_row.organisation_id: + summary.organisation = project_row.organisation_id + summary.organisation_name = project_row.organisation_name + summary.organisation_slug = project_row.organisation_slug + summary.organisation_logo = project_row.organisation_logo + + # Mapping types + if project_row.mapping_types: + summary.mapping_types = ( + [ + MappingTypes(mapping_type).name + for mapping_type in project_row.mapping_types + ] + if project_row.mapping_types + else [] + ) - if project_stats.average_mapping_time <= 0: - mapped_avg = ( - db.session.query(nz.c.zoom, (func.avg(nz.c.ts)).label("avg")) - .filter(nz.c.action == TaskStatus.LOCKED_FOR_MAPPING.name) - .group_by(nz.c.zoom) - .all() + # Custom editor + custom_editor_query = """ + SELECT name, description, url + FROM project_custom_editors + WHERE project_id = :project_id + """ + custom_editor_row = await db.fetch_one( + custom_editor_query, {"project_id": project_id} + ) + if custom_editor_row: + summary.custom_editor = CustomEditorDTO( + name=custom_editor_row.name, + description=custom_editor_row.description, + url=custom_editor_row.url, ) - if len(mapped_avg) != 0: - mapping_time = sum([t.avg.total_seconds() for t in mapped_avg]) / len( - mapped_avg - ) - project_stats.average_mapping_time = mapping_time - - if project_stats.average_validation_time <= 0: - val_avg = ( - db.session.query(nz.c.zoom, (func.avg(nz.c.ts)).label("avg")) - .filter(nz.c.action == TaskStatus.LOCKED_FOR_VALIDATION.name) - .group_by(nz.c.zoom) - .all() + + if summary.private: + allowed_user_ids = ( + project_row.allowed_users if project_row.allowed_users else [] ) - if len(val_avg) != 0: - validation_time = sum([t.avg.total_seconds() for t in val_avg]) / len( - val_avg + if allowed_user_ids: + query = "SELECT username FROM users WHERE id = ANY(:allowed_user_ids)" + allowed_users = await db.fetch_all( + query, {"allowed_user_ids": allowed_user_ids} ) - project_stats.average_validation_time = validation_time - - time_to_finish_mapping = ( - self.total_tasks - - (self.tasks_mapped + self.tasks_bad_imagery + self.tasks_validated) - ) * project_stats.average_mapping_time - project_stats.time_to_finish_mapping = time_to_finish_mapping - project_stats.time_to_finish_validating = ( - self.total_tasks - (self.tasks_validated + self.tasks_bad_imagery) - ) * project_stats.average_validation_time - - return project_stats + summary.allowed_users = [user["username"] for user in allowed_users] + else: + summary.allowed_users = [] - def get_project_summary( - self, preferred_locale, calculate_completion=True - ) -> ProjectSummary: - """Create Project Summary model for postgis project object""" - summary = ProjectSummary() - summary.project_id = self.id - priority = self.priority - if priority == 0: - summary.priority = "URGENT" - elif priority == 1: - summary.priority = "HIGH" - elif priority == 2: - summary.priority = "MEDIUM" - else: - summary.priority = "LOW" - summary.author = User.get_by_id(self.author_id).username - summary.default_locale = self.default_locale - summary.country_tag = self.country - summary.changeset_comment = self.changeset_comment - summary.due_date = self.due_date - summary.created = self.created - summary.last_updated = self.last_updated - summary.osmcha_filter_id = self.osmcha_filter_id - summary.difficulty = ProjectDifficulty(self.difficulty).name - summary.mapping_permission = MappingPermission(self.mapping_permission).name - summary.validation_permission = ValidationPermission( - self.validation_permission - ).name - summary.random_task_selection_enforced = self.enforce_random_task_selection - summary.private = self.private - summary.license_id = self.license_id - summary.status = ProjectStatus(self.status).name - summary.id_presets = self.id_presets - summary.extra_id_params = self.extra_id_params - summary.rapid_power_user = self.rapid_power_user - summary.imagery = self.imagery - if self.organisation_id: - summary.organisation = self.organisation_id - summary.organisation_name = self.organisation.name - summary.organisation_slug = self.organisation.slug - summary.organisation_logo = self.organisation.logo - - if self.campaign: - summary.campaigns = [i.as_dto() for i in self.campaign] - - # Cast MappingType values to related string array - mapping_types_array = [] - if self.mapping_types: - for mapping_type in self.mapping_types: - mapping_types_array.append(MappingTypes(mapping_type).name) - summary.mapping_types = mapping_types_array - - if self.mapping_editors: - mapping_editors = [] - for mapping_editor in self.mapping_editors: - mapping_editors.append(Editors(mapping_editor).name) - - summary.mapping_editors = mapping_editors - - if self.validation_editors: - validation_editors = [] - for validation_editor in self.validation_editors: - validation_editors.append(Editors(validation_editor).name) - - summary.validation_editors = validation_editors - - if self.custom_editor: - summary.custom_editor = self.custom_editor.as_dto() - - # If project is private, fetch list of allowed users - if self.private: - allowed_users = [] - for user in self.allowed_users: - allowed_users.append(user.username) - summary.allowed_users = allowed_users - - centroid_geojson = db.session.scalar(self.centroid.ST_AsGeoJSON()) - summary.aoi_centroid = geojson.loads(centroid_geojson) + # AOI centroid + summary.aoi_centroid = geojson.loads(project_row.centroid) + # Calculate completion percentages if requested if calculate_completion: - summary.percent_mapped = self.calculate_tasks_percent("mapped") - summary.percent_validated = self.calculate_tasks_percent("validated") - summary.percent_bad_imagery = self.calculate_tasks_percent("bad_imagery") + summary.percent_mapped = Project.calculate_tasks_percent( + "mapped", + project_row.tasks_mapped, + project_row.tasks_validated, + project_row.total_tasks, + project_row.tasks_bad_imagery, + ) + summary.percent_validated = Project.calculate_tasks_percent( + "validated", + project_row.tasks_validated, + project_row.tasks_validated, + project_row.total_tasks, + project_row.tasks_bad_imagery, + ) + summary.percent_bad_imagery = Project.calculate_tasks_percent( + "bad_imagery", + project_row.tasks_mapped, + project_row.tasks_validated, + project_row.total_tasks, + project_row.tasks_bad_imagery, + ) + + # Project campaigns + query = """ + SELECT c.* + FROM campaigns c + INNER JOIN campaign_projects cp ON c.id = cp.campaign_id + WHERE cp.project_id = :project_id + """ + campaigns = await db.fetch_all(query=query, values={"project_id": project_id}) + campaigns_dto = ( + [CampaignDTO(**campaign) for campaign in campaigns] if campaigns else [] + ) + summary.campaigns = campaigns_dto + + # Project teams + query = """ + SELECT + pt.team_id, + t.name AS team_name, + pt.role + FROM project_teams pt + JOIN teams t ON pt.team_id = t.id + WHERE pt.project_id = :project_id + """ + teams = await db.fetch_all(query, {"project_id": project_row["id"]}) summary.project_teams = [ ProjectTeamDTO( - dict( - team_id=t.team.id, - team_name=t.team.name, - role=TeamRoles(t.role).name, - ) + team_id=team["team_id"], + team_name=team["team_name"], + role=TeamRoles(team["role"]).name, ) - for t in self.teams + for team in teams ] - - project_info = ProjectInfo.get_dto_for_locale( - self.id, preferred_locale, self.default_locale + # Project info for the preferred locale + project_info = await Project.get_dto_for_locale( + project_row["id"], preferred_locale, project_row["default_locale"], db ) summary.project_info = project_info return summary - def get_project_title(self, preferred_locale): - project_info = ProjectInfo.get_dto_for_locale( - self.id, preferred_locale, self.default_locale + @staticmethod + async def get_dto_for_locale( + project_id: int, preferred_locale: str, default_locale: str, db: Database + ) -> ProjectInfoDTO: + """Get project info for the preferred locale.""" + query = """ + SELECT + name, + locale, + short_description, + description, + instructions + FROM project_info + WHERE project_id = :project_id AND locale = :preferred_locale + """ + project_info = await db.fetch_one( + query, {"project_id": project_id, "preferred_locale": preferred_locale} ) - return project_info.name - @staticmethod - def get_project_total_contributions(project_id: int) -> int: - project_contributors_count = ( - TaskHistory.query.with_entities(TaskHistory.user_id) - .filter( - TaskHistory.project_id == project_id, TaskHistory.action != "COMMENT" + if not project_info: + # Fallback to default locale if preferred locale is not available + project_info = await db.fetch_one( + query, {"project_id": project_id, "preferred_locale": default_locale} ) - .distinct(TaskHistory.user_id) - .count() - ) + return ProjectInfoDTO(**project_info) if project_info else None + + @staticmethod + async def get_project_total_contributions(project_id: int, db) -> int: + query = """ + SELECT COUNT(DISTINCT user_id) + FROM task_history + WHERE project_id = :project_id AND action != 'COMMENT' + """ - return project_contributors_count + result = await db.fetch_one(query=query, values={"project_id": project_id}) - def get_aoi_geometry_as_geojson(self): + # fetch_one returns a single record, use index [0] to get the first column value + return result[0] if result else 0 + + @staticmethod + async def get_aoi_geometry_as_geojson(project_id: int, db: Database) -> dict: """Helper which returns the AOI geometry as a geojson object""" - with db.engine.connect() as conn: - aoi_geojson = conn.execute(self.geometry.ST_AsGeoJSON()).scalar() - return geojson.loads(aoi_geojson) + + query = """ + SELECT ST_AsGeoJSON(geometry) AS aoi_geojson + FROM projects + WHERE id = :project_id + """ + + result = await db.fetch_one(query, {"project_id": project_id}) + if not result: + raise ValueError("Project not found or geometry is missing") + aoi_geojson = geojson.loads(result["aoi_geojson"]) + return aoi_geojson def get_project_teams(self): """Helper to return teams with members so we can handle permissions""" @@ -989,231 +1609,422 @@ def get_project_teams(self): return project_teams - @staticmethod - @cached(active_mappers_cache) - def get_active_mappers(project_id) -> int: - """Get count of Locked tasks as a proxy for users who are currently active on the project""" + # def get_project_title(self, preferred_locale): + # project_info = ProjectInfo.get_dto_for_locale( + # self.id, preferred_locale, self.default_locale + # ) + # return project_info.name - return ( - Task.query.filter( - Task.task_status.in_( - ( - TaskStatus.LOCKED_FOR_MAPPING.value, - TaskStatus.LOCKED_FOR_VALIDATION.value, - ) - ) - ) - .filter(Task.project_id == project_id) - .distinct(Task.locked_by) - .count() + @staticmethod + async def get_project_title(db: Database, project_id: int, preferred_locale): + project_info = await ProjectInfo.get_dto_for_locale( + db, project_id, preferred_locale ) + return project_info.name - def _get_project_and_base_dto(self): - """Populates a project DTO with properties common to all roles""" - base_dto = ProjectDTO() - base_dto.project_id = self.id - base_dto.project_status = ProjectStatus(self.status).name - base_dto.default_locale = self.default_locale - base_dto.project_priority = ProjectPriority(self.priority).name - base_dto.area_of_interest = self.get_aoi_geometry_as_geojson() - base_dto.aoi_bbox = shape(base_dto.area_of_interest).bounds - base_dto.mapping_permission = MappingPermission(self.mapping_permission).name - base_dto.validation_permission = ValidationPermission( - self.validation_permission - ).name - base_dto.enforce_random_task_selection = self.enforce_random_task_selection - base_dto.private = self.private - base_dto.difficulty = ProjectDifficulty(self.difficulty).name - base_dto.changeset_comment = self.changeset_comment - base_dto.osmcha_filter_id = self.osmcha_filter_id - base_dto.due_date = self.due_date - base_dto.imagery = self.imagery - base_dto.josm_preset = self.josm_preset - base_dto.id_presets = self.id_presets - base_dto.extra_id_params = self.extra_id_params - base_dto.rapid_power_user = self.rapid_power_user - base_dto.country_tag = self.country - base_dto.organisation = self.organisation_id - base_dto.license_id = self.license_id - base_dto.created = self.created - base_dto.last_updated = self.last_updated - base_dto.author = User.get_by_id(self.author_id).username - base_dto.active_mappers = Project.get_active_mappers(self.id) - base_dto.task_creation_mode = TaskCreationMode(self.task_creation_mode).name - - base_dto.percent_mapped = self.calculate_tasks_percent("mapped") - base_dto.percent_validated = self.calculate_tasks_percent("validated") - base_dto.percent_bad_imagery = self.calculate_tasks_percent("bad_imagery") - - base_dto.project_teams = [ - ProjectTeamDTO( - dict( - team_id=t.team.id, - team_name=t.team.name, - role=TeamRoles(t.role).name, - ) - ) - for t in self.teams - ] + @staticmethod + async def get_active_mappers(project_id: int, database: Database) -> int: + """Get count of Locked tasks as a proxy for users who are currently active on the project""" + query = """ + SELECT COUNT(*) + FROM ( + SELECT DISTINCT locked_by + FROM tasks + WHERE task_status IN (:locked_for_mapping, :locked_for_validation) + AND project_id = :project_id + ) AS active_mappers + """ - if self.custom_editor: - base_dto.custom_editor = self.custom_editor.as_dto() + values = { + "project_id": project_id, + "locked_for_mapping": TaskStatus.LOCKED_FOR_MAPPING.value, + "locked_for_validation": TaskStatus.LOCKED_FOR_VALIDATION.value, + } - if self.private: - # If project is private it should have a list of allowed users - allowed_usernames = [] - for user in self.allowed_users: - allowed_usernames.append(user.username) - base_dto.allowed_usernames = allowed_usernames + count = await database.fetch_val(query, values) + # Handle the case where count might be None + return count or 0 - if self.mapping_types: - mapping_types = [] - for mapping_type in self.mapping_types: - mapping_types.append(MappingTypes(mapping_type).name) + @staticmethod + async def get_project_and_base_dto(project_id: int, db: Database) -> ProjectDTO: + """Populates a project DTO with properties common to all roles""" - base_dto.mapping_types = mapping_types + # Raw SQL query to fetch project data with date formatting + query = """ + SELECT p.id as project_id, p.status as project_status, p.default_locale, p.priority as project_priority, + p.mapping_permission, p.validation_permission, p.enforce_random_task_selection, p.private, + p.difficulty, p.changeset_comment, p.osmcha_filter_id, + TO_CHAR(COALESCE(p.due_date, NULL), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as due_date, + p.imagery, p.josm_preset, p.id_presets, p.extra_id_params, p.rapid_power_user, p.country, + p.organisation_id, p.license_id, + TO_CHAR(p.created, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as created, + TO_CHAR(p.last_updated, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as last_updated, + u.username as author, + p.total_tasks, p.tasks_mapped, p.tasks_validated, p.tasks_bad_imagery, p.task_creation_mode, + p.mapping_types, p.mapping_editors, p.validation_editors, p.organisation_id + FROM projects p + LEFT JOIN users u ON p.author_id = u.id + WHERE p.id = :project_id + """ + # Execute query and fetch the result + record = await db.fetch_one(query, {"project_id": project_id}) - if self.campaign: - base_dto.campaigns = [i.as_dto() for i in self.campaign] + if not record: + raise ValueError("Project not found") - if self.mapping_editors: - mapping_editors = [] - for mapping_editor in self.mapping_editors: - mapping_editors.append(Editors(mapping_editor).name) + area_of_interest = await Project.get_aoi_geometry_as_geojson(project_id, db) + aoi_bbox = shape(area_of_interest).bounds + active_mappers = await Project.get_active_mappers(project_id, db) - base_dto.mapping_editors = mapping_editors + # stats + tasks_mapped = record.tasks_mapped + tasks_validated = record.tasks_validated + total_tasks = record.total_tasks + tasks_bad_imagery = record.tasks_bad_imagery - if self.validation_editors: - validation_editors = [] - for validation_editor in self.validation_editors: - validation_editors.append(Editors(validation_editor).name) + percent_mapped = Project.calculate_tasks_percent( + "mapped", tasks_mapped, tasks_validated, total_tasks, tasks_bad_imagery + ) + percent_validated = Project.calculate_tasks_percent( + "validated", + tasks_validated, + tasks_validated, + total_tasks, + tasks_bad_imagery, + ) + percent_bad_imagery = Project.calculate_tasks_percent( + "bad_imagery", tasks_mapped, tasks_validated, total_tasks, tasks_bad_imagery + ) - base_dto.validation_editors = validation_editors + # Convert record to DTO + project_dto = ProjectDTO( + project_id=record.project_id, + project_status=ProjectStatus(record.project_status).name, + default_locale=record.default_locale, + project_priority=ProjectPriority(record.project_priority).name, + area_of_interest=area_of_interest, + aoi_bbox=aoi_bbox, + mapping_permission=MappingPermission(record.mapping_permission).name, + validation_permission=ValidationPermission( + record.validation_permission + ).name, + enforce_random_task_selection=record.enforce_random_task_selection, + private=record.private, + difficulty=ProjectDifficulty(record.difficulty).name, + changeset_comment=record.changeset_comment, + osmcha_filter_id=record.osmcha_filter_id, + due_date=record.due_date, + imagery=record.imagery, + josm_preset=record.josm_preset, + id_presets=record.id_presets, + extra_id_params=record.extra_id_params, + rapid_power_user=record.rapid_power_user, + country_tag=record.country, + organisation=record.organisation_id, + license_id=record.license_id, + created=record.created, + last_updated=record.last_updated, + author=record.author, + active_mappers=active_mappers, + task_creation_mode=TaskCreationMode(record.task_creation_mode).name, + mapping_types=( + [ + MappingTypes(mapping_type).name + for mapping_type in record.mapping_types + ] + if record.mapping_types is not None + else [] + ), + mapping_editors=( + [Editors(editor).name for editor in record.mapping_editors] + if record.mapping_editors + else [] + ), + validation_editors=( + [Editors(editor).name for editor in record.validation_editors] + if record.validation_editors + else [] + ), + percent_mapped=percent_mapped, + percent_validated=percent_validated, + percent_bad_imagery=percent_bad_imagery, + ) + # Fetch project teams + teams_query = """ + SELECT + t.id AS team_id, + t.name AS team_name, + pt.role + FROM + project_teams pt + JOIN + teams t ON t.id = pt.team_id + WHERE + pt.project_id = :project_id + """ + teams = await db.fetch_all(teams_query, {"project_id": project_id}) + project_dto.project_teams = ( + [ + ProjectTeamDTO(**{**team, "role": TeamRoles(team["role"]).name}) + for team in teams + ] + if teams + else [] + ) - if self.priority_areas: - geojson_areas = [] - for priority_area in self.priority_areas: - geojson_areas.append(priority_area.get_as_geojson()) + custom_editor = await db.fetch_one( + """ + SELECT project_id, name, description, url + FROM project_custom_editors + WHERE project_id = :project_id + """, + {"project_id": project_id}, + ) - base_dto.priority_areas = geojson_areas + if custom_editor: + project_dto.custom_editor = CustomEditorDTO(**custom_editor) + + if project_dto.private: + # Fetch allowed usernames using the intermediate table + allowed_users_query = """ + SELECT u.username + FROM project_allowed_users pau + JOIN users u ON pau.user_id = u.id + WHERE pau.project_id = :project_id + """ + allowed_usernames = await db.fetch_all( + allowed_users_query, {"project_id": project_id} + ) + project_dto.allowed_usernames = ( + [user.username for user in allowed_usernames] + if allowed_usernames + else [] + ) - base_dto.interests = [ - InterestDTO(dict(id=i.id, name=i.name)) for i in self.interests - ] + campaigns_query = """ + SELECT c.id, c.name + FROM campaigns c + JOIN campaign_projects cp ON c.id = cp.campaign_id + WHERE cp.project_id = :project_id + """ + campaigns = await db.fetch_all(campaigns_query, {"project_id": project_id}) + project_dto.campaigns = [ListCampaignDTO(**c) for c in campaigns] + + priority_areas_query = """ + SELECT ST_AsGeoJSON(pa.geometry) as geojson + FROM priority_areas pa + JOIN project_priority_areas ppa ON pa.id = ppa.priority_area_id + WHERE ppa.project_id = :project_id + """ + priority_areas = await db.fetch_all( + priority_areas_query, {"project_id": project_id} + ) + project_dto.priority_areas = ( + [geojson.loads(area["geojson"]) for area in priority_areas] + if priority_areas + else None + ) - return self, base_dto + interests_query = """ + SELECT i.id, i.name + FROM interests i + JOIN project_interests pi ON i.id = pi.interest_id + WHERE pi.project_id = :project_id + """ + interests = await db.fetch_all(interests_query, {"project_id": project_id}) + project_dto.interests = [InterestDTO(**i) for i in interests] + return project_dto - def as_dto_for_mapping( - self, authenticated_user_id: int = None, locale: str = "en", abbrev: bool = True + @staticmethod + async def as_dto_for_mapping( + project_id: int, + db: Database, + authenticated_user_id: int = None, + locale: str = "en", + abbrev: bool = True, ) -> Optional[ProjectDTO]: """Creates a Project DTO suitable for transmitting to mapper users""" - project, project_dto = self._get_project_and_base_dto() + project_dto = await Project.get_project_and_base_dto(project_id, db) + if abbrev is False: - project_dto.tasks = Task.get_tasks_as_geojson_feature_collection( - self.id, None + project_dto.tasks = await Task.get_tasks_as_geojson_feature_collection( + db, project_id, None ) else: - project_dto.tasks = Task.get_tasks_as_geojson_feature_collection_no_geom( - self.id + project_dto.tasks = ( + await Task.get_tasks_as_geojson_feature_collection_no_geom( + db, project_id + ) ) - project_dto.project_info = ProjectInfo.get_dto_for_locale( - self.id, locale, project.default_locale + + project_dto.project_info = await ProjectInfo.get_dto_for_locale( + db, project_id, locale, project_dto.default_locale ) - if project.organisation_id: - project_dto.organisation = project.organisation.id - project_dto.organisation_name = project.organisation.name - project_dto.organisation_logo = project.organisation.logo - project_dto.organisation_slug = project.organisation.slug - project_dto.project_info_locales = ProjectInfo.get_dto_for_all_locales(self.id) - return project_dto + if project_dto.organisation: + # Fetch organisation details + org_query = """ + SELECT + id AS "organisation_id", + name, + slug, + logo + FROM organisations + WHERE id = :organisation_id + """ + org_record = await db.fetch_one( + org_query, values={"organisation_id": project_dto.organisation} + ) + if org_record: + project_dto.organisation_name = org_record.name + project_dto.organisation_logo = org_record.logo + project_dto.organisation_slug = org_record.slug - def tasks_as_geojson( - self, task_ids_str: str, order_by=None, order_by_type="ASC", status=None - ): - """Creates a geojson of all areas""" - project_tasks = Task.get_tasks_as_geojson_feature_collection( - self.id, task_ids_str, order_by, order_by_type, status + project_dto.project_info_locales = await ProjectInfo.get_dto_for_all_locales( + db, project_id ) - return project_tasks + return project_dto @staticmethod - def get_all_countries(): - query = ( - db.session.query(func.unnest(Project.country).label("country")) - .distinct() - .order_by("country") - .all() + async def tasks_as_geojson( + db: Database, + project_id: int, + task_ids_str: Optional[str], + order_by: Optional[str] = None, + order_by_type: str = "ASC", + status: Optional[int] = None, + ): + return await Task.get_tasks_as_geojson_feature_collection( + db, project_id, task_ids_str, order_by, order_by_type, status ) - tags_dto = TagsDTO() - tags_dto.tags = [r[0] for r in query] + + @staticmethod + async def get_all_countries(database: Database) -> TagsDTO: + # Raw SQL query to unnest the country field, select distinct values, and order by country + query = """ + SELECT DISTINCT UNNEST(country) AS country + FROM projects + ORDER BY country + """ + rows = await database.fetch_all(query=query) + countries = [row["country"] for row in rows] + tags_dto = TagsDTO(tags=countries) return tags_dto - def calculate_tasks_percent(self, target): - """Calculates percentages of contributions""" + @staticmethod + def calculate_tasks_percent( + target: str, + tasks_mapped: int, + tasks_validated: int, + total_tasks: int, + tasks_bad_imagery: int, + ) -> int: + """Calculates percentages of contributions based on provided statistics.""" try: if target == "mapped": return int( - (self.tasks_mapped + self.tasks_validated) - / (self.total_tasks - self.tasks_bad_imagery) + (tasks_mapped + tasks_validated) + / (total_tasks - tasks_bad_imagery) * 100 ) elif target == "validated": - return int( - self.tasks_validated - / (self.total_tasks - self.tasks_bad_imagery) - * 100 - ) + return int(tasks_validated / (total_tasks - tasks_bad_imagery) * 100) elif target == "bad_imagery": - return int((self.tasks_bad_imagery / self.total_tasks) * 100) + return int((tasks_bad_imagery / total_tasks) * 100) elif target == "project_completion": # To calculate project completion we assign 2 points to each task # one for mapping and one for validation return int( - (self.tasks_mapped + (self.tasks_validated * 2)) - / ((self.total_tasks - self.tasks_bad_imagery) * 2) + (tasks_mapped + (tasks_validated * 2)) + / ((total_tasks - tasks_bad_imagery) * 2) * 100 ) except ZeroDivisionError: return 0 - def as_dto_for_admin(self, project_id): + @staticmethod + async def as_dto_for_admin(project_id: int, db: Database): """Creates a Project DTO suitable for transmitting to project admins""" - project, project_dto = self._get_project_and_base_dto() - - if project is None: - return None + project_dto = await Project.get_project_and_base_dto(project_id, db) - project_dto.project_info_locales = ProjectInfo.get_dto_for_all_locales( - project_id + project_dto.project_info_locales = await ProjectInfo.get_dto_for_all_locales( + db, project_id ) return project_dto - def create_or_update_interests(self, interests_ids): + async def create_or_update_interests(self, interests_ids, db): self.interests = [] - objs = [Interest.get_by_id(i) for i in interests_ids] + objs = [Interest.get_by_id(i, db) for i in interests_ids] self.interests.extend(objs) - db.session.commit() - - @staticmethod - def get_project_campaigns(project_id: int): query = ( - Campaign.query.join(campaign_projects) - .filter(campaign_projects.c.project_id == project_id) - .all() + update(Project) + .where(Project.id == self.id) + .values(interests=self.interests) ) - campaign_list = [] - for campaign in query: - campaign_dto = CampaignDTO() - campaign_dto.id = campaign.id - campaign_dto.name = campaign.name - campaign_list.append(campaign_dto) + # Execute the update query using the async `db.execute` + project = await db.execute(query) + + return project + @staticmethod + async def get_project_campaigns(project_id: int, db: Database): + query = """ + SELECT c.id, c.name + FROM campaign_projects cp + JOIN campaigns c ON cp.campaign_id = c.id + WHERE cp.project_id = :project_id + """ + rows = await db.fetch_all(query=query, values={"project_id": project_id}) + + campaign_list = [ListCampaignDTO(**row) for row in rows] return campaign_list + @staticmethod + async def clear_existing_priority_areas(db: Database, project_id: int): + """Clear existing priority area links and delete the corresponding priority areas for the given project ID.""" + + existing_priority_area_ids_query = """ + SELECT priority_area_id + FROM project_priority_areas + WHERE project_id = :project_id; + """ + existing_priority_area_ids = await db.fetch_all( + query=existing_priority_area_ids_query, values={"project_id": project_id} + ) + existing_ids = [ + record["priority_area_id"] for record in existing_priority_area_ids + ] + + clear_links_query = """ + DELETE FROM project_priority_areas + WHERE project_id = :project_id; + """ + await db.execute(query=clear_links_query, values={"project_id": project_id}) + + if existing_ids: + delete_priority_areas_query = """ + DELETE FROM priority_areas + WHERE id = ANY(:ids); + """ + # Pass the list as an array using PostgreSQL's array syntax + await db.execute( + query=delete_priority_areas_query, values={"ids": existing_ids} + ) + + async def update_project_author(project_id: int, new_author_id: int, db: Database): + query = """ + UPDATE projects + SET author_id = :new_author_id + WHERE id = :project_id + """ + values = {"new_author_id": new_author_id, "project_id": project_id} + + # Execute the query + await db.execute(query=query, values=values) + # Add index on project geometry -db.Index("idx_geometry", Project.geometry, postgresql_using="gist") +Index("idx_geometry", Project.geometry, postgresql_using="gist") diff --git a/backend/models/postgis/project_chat.py b/backend/models/postgis/project_chat.py index 9070bd917c..c6e150390b 100644 --- a/backend/models/postgis/project_chat.py +++ b/backend/models/postgis/project_chat.py @@ -1,34 +1,38 @@ import bleach +from databases import Database +from loguru import logger from markdown import markdown -from flask import current_app -from backend import db +from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from backend.db import Base +from backend.models.dtos.message_dto import ( + ChatMessageDTO, + ListChatMessageDTO, + Pagination, + ProjectChatDTO, +) from backend.models.postgis.user import User from backend.models.postgis.utils import timestamp -from backend.models.dtos.message_dto import ChatMessageDTO, ProjectChatDTO, Pagination -class ProjectChat(db.Model): +class ProjectChat(Base): """Contains all project info localized into supported languages""" __tablename__ = "project_chat" - id = db.Column(db.BigInteger, primary_key=True) - project_id = db.Column( - db.Integer, db.ForeignKey("projects.id"), index=True, nullable=False - ) - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) - time_stamp = db.Column(db.DateTime, nullable=False, default=timestamp) - message = db.Column(db.String, nullable=False) + id = Column(BigInteger, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), index=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + time_stamp = Column(DateTime, nullable=False, default=timestamp) + message = Column(String, nullable=False) # Relationships - posted_by = db.relationship(User, foreign_keys=[user_id]) + posted_by = relationship(User, foreign_keys=[user_id]) @classmethod - def create_from_dto(cls, dto: ChatMessageDTO): + async def create_from_dto(cls, dto: ChatMessageDTO, db: Database): """Creates a new ProjectInfo class from dto, used from project edit""" - current_app.logger.debug("Create chat message from DTO") - new_message = cls() - new_message.project_id = dto.project_id - new_message.user_id = dto.user_id + logger.debug("Create chat message from DTO") # Use bleach to remove any potential mischief allowed_tags = [ @@ -57,36 +61,83 @@ def create_from_dto(cls, dto: ChatMessageDTO): attributes=allowed_atrributes, ) clean_message = bleach.linkify(clean_message) - new_message.message = clean_message - db.session.add(new_message) - return new_message + query = """ + INSERT INTO project_chat (project_id, user_id, message, time_stamp) + VALUES (:project_id, :user_id, :message, :time_stamp) + RETURNING id, project_id, user_id, message, time_stamp + """ + + values = { + "project_id": dto.project_id, + "user_id": dto.user_id, + "time_stamp": dto.timestamp, + "message": clean_message, + } + + new_message_id = await db.execute(query=query, values=values) + new_message = await db.fetch_one( + """ + SELECT pc.id, pc.message, pc.project_id, pc.time_stamp, u.id AS user_id, u.username, u.picture_url + FROM project_chat pc + JOIN users u ON u.id = pc.user_id + WHERE pc.id = :message_id + """, + {"message_id": new_message_id}, + ) + return ListChatMessageDTO( + id=new_message["id"], + message=new_message["message"], + picture_url=new_message["picture_url"], + timestamp=new_message["time_stamp"], + username=new_message["username"], + ) @staticmethod - def get_messages(project_id: int, page: int, per_page: int = 20) -> ProjectChatDTO: + async def get_messages( + project_id: int, db: Database, page: int, per_page: int = 20 + ) -> ProjectChatDTO: """Get all messages on the project""" - project_messages = ( - ProjectChat.query.filter_by(project_id=project_id) - .order_by(ProjectChat.time_stamp.desc()) - .paginate(page=page, per_page=per_page, error_out=True) - ) + offset = (page - 1) * per_page + count_query = """ + SELECT COUNT(*) + FROM project_chat + WHERE project_id = :project_id + """ + messages_query = """ + SELECT pc.id, pc.message, pc.project_id, pc.time_stamp, u.id AS user_id, u.username, u.picture_url + FROM project_chat pc + JOIN users u ON u.id = pc.user_id + WHERE pc.project_id = :project_id + ORDER BY pc.time_stamp DESC + LIMIT :limit OFFSET :offset + """ - dto = ProjectChatDTO() + total_count = await db.fetch_val(count_query, {"project_id": project_id}) - if project_messages.total == 0: - return dto + if total_count == 0: + return ProjectChatDTO() # Return empty DTO if no messages - for message in project_messages.items: - chat_dto = ChatMessageDTO() - chat_dto.message = message.message - chat_dto.username = message.posted_by.username - chat_dto.picture_url = message.posted_by.picture_url - chat_dto.timestamp = message.time_stamp - chat_dto.id = message.id + messages = await db.fetch_all( + messages_query, + {"project_id": project_id, "limit": per_page, "offset": offset}, + ) + dto = ProjectChatDTO() + + for message in messages: + chat_dto = ListChatMessageDTO( + id=message["id"], + message=message["message"], + picture_url=message["picture_url"], + timestamp=message["time_stamp"], + username=message["username"], + ) dto.chat.append(chat_dto) - dto.pagination = Pagination(project_messages) + dto.pagination = Pagination.from_total_count( + page=page, per_page=per_page, total=total_count + ) return dto diff --git a/backend/models/postgis/project_info.py b/backend/models/postgis/project_info.py index d28253b12f..eca6bed91e 100644 --- a/backend/models/postgis/project_info.py +++ b/backend/models/postgis/project_info.py @@ -1,30 +1,42 @@ -from flask import current_app -from sqlalchemy.dialects.postgresql import TSVECTOR from typing import List -from backend import db + +from databases import Database +from sqlalchemy import ( + Column, + ForeignKey, + Index, + Integer, + String, + insert, + inspect, + update, +) +from sqlalchemy.dialects.postgresql import TSVECTOR + +from backend.db import Base from backend.models.dtos.project_dto import ProjectInfoDTO -class ProjectInfo(db.Model): +class ProjectInfo(Base): """Contains all project info localized into supported languages""" __tablename__ = "project_info" - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) - locale = db.Column(db.String(10), primary_key=True) - name = db.Column(db.String(512)) - short_description = db.Column(db.String) - description = db.Column(db.String) - instructions = db.Column(db.String) - project_id_str = db.Column(db.String) - text_searchable = db.Column( + project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) + locale = Column(String(10), primary_key=True) + name = Column(String(512)) + short_description = Column(String) + description = Column(String) + instructions = Column(String) + project_id_str = Column(String) + text_searchable = Column( TSVECTOR ) # This contains searchable text and is populated by a DB Trigger - per_task_instructions = db.Column(db.String) + per_task_instructions = Column(String) __table_args__ = ( - db.Index("idx_project_info_composite", "locale", "project_id"), - db.Index("textsearch_idx", "text_searchable"), + Index("idx_project_info_composite", "locale", "project_id"), + Index("textsearch_idx", "text_searchable"), {}, ) @@ -37,15 +49,29 @@ def create_from_name(cls, name: str): return new_info @classmethod - def create_from_dto(cls, dto: ProjectInfoDTO): + async def create_from_dto(cls, dto: ProjectInfoDTO, project_id: int, db: Database): """Creates a new ProjectInfo class from dto, used from project edit""" - new_info = cls() - new_info.update_from_dto(dto) - return new_info + self = cls() + self.locale = dto.locale + self.name = dto.name + self.project_id = project_id + self.project_id_str = str(project_id) # Allows project_id to be searched - def update_from_dto(self, dto: ProjectInfoDTO): + # Note project info not bleached on basis that admins are trusted users and shouldn't be doing anything bad + self.short_description = dto.short_description + self.description = dto.description + self.instructions = dto.instructions + self.per_task_instructions = dto.per_task_instructions + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + query = insert(ProjectInfo.__table__).values(**columns) + result = await db.execute(query) + return result + + async def update_from_dto(self, dto: ProjectInfoDTO, db: Database): """Updates existing ProjectInfo from supplied DTO""" - self.locale = dto.locale + # self.locale = dto.locale self.name = dto.name self.project_id_str = str(self.project_id) # Allows project_id to be searched @@ -54,77 +80,149 @@ def update_from_dto(self, dto: ProjectInfoDTO): self.description = dto.description self.instructions = dto.instructions self.per_task_instructions = dto.per_task_instructions + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + columns.pop("project_id", None) + columns.pop("locale", None) + query = ( + update(ProjectInfo.__table__) + .where( + ProjectInfo.project_id == self.project_id, + ProjectInfo.locale == self.locale, + ) + .values(**columns) + ) + result = await db.execute(query) + return result @staticmethod - def get_dto_for_locale(project_id, locale, default_locale="en") -> ProjectInfoDTO: + async def get_dto_for_locale( + db: Database, project_id: int, locale: str, default_locale: str = "en" + ) -> ProjectInfoDTO: """ - Gets the projectInfoDTO for the project for the requested locale. If not found, then the default locale is used + Gets the ProjectInfoDTO for the project for the requested locale. + If not found, then the default locale is used. + :param db: The async database connection :param project_id: ProjectID in scope - :param locale: locale requested by user - :param default_locale: default locale of project + :param locale: Locale requested by user + :param default_locale: Default locale of project + :return: ProjectInfoDTO :raises: ValueError if no info found for Default Locale """ - project_info = ProjectInfo.query.filter_by( - project_id=project_id, locale=locale - ).one_or_none() - + query = """ + SELECT * FROM project_info + WHERE project_id = :project_id AND locale = :locale + """ + # Execute the query for the requested locale + project_info = await db.fetch_one( + query, values={"project_id": project_id, "locale": locale} + ) if project_info is None: - # If project is none, get default locale and don't worry about empty translations - project_info = ProjectInfo.query.filter_by( - project_id=project_id, locale=default_locale - ).one_or_none() - return project_info.get_dto() + # Define the SQL query to get project info by default locale + query_default = """ + SELECT * FROM project_info + WHERE project_id = :project_id AND locale = :default_locale + """ + + # Execute the query for the default locale + project_info = await db.fetch_one( + query_default, + values={"project_id": project_id, "default_locale": default_locale}, + ) + + if project_info is None: + error_message = f"BAD DATA: No info for project {project_id},locale: {locale},default {default_locale}" + raise ValueError(error_message) + + return ProjectInfoDTO(**project_info) if locale == default_locale: - # If locale == default_locale don't need to worry about empty translations - return project_info.get_dto() + # Return the DTO for the default locale + return ProjectInfoDTO(**project_info) + # Define the SQL query to get project info by default locale for partial translations + query_default = """ + SELECT * FROM project_info + WHERE project_id = :project_id AND locale = :default_locale + """ - default_locale = ProjectInfo.query.filter_by( - project_id=project_id, locale=default_locale - ).one_or_none() + # Execute the query for the default locale + default_locale_info = await db.fetch_one( + query_default, + values={"project_id": project_id, "default_locale": default_locale}, + ) - if default_locale is None: + if default_locale_info is None: error_message = f"BAD DATA: no info for project {project_id}, locale: {locale}, default {default_locale}" - current_app.logger.critical(error_message) raise ValueError(error_message) - # Pass thru default_locale in case of partial translation - return project_info.get_dto(default_locale) - - def get_dto(self, default_locale=ProjectInfoDTO()) -> ProjectInfoDTO: - """ - Get DTO for current ProjectInfo - :param default_locale: The default locale string for any empty fields - """ - project_info_dto = ProjectInfoDTO() - project_info_dto.locale = self.locale - project_info_dto.name = self.name if self.name else default_locale.name - project_info_dto.description = ( - self.description if self.description else default_locale.description + combined_info = ProjectInfoDTO(locale=project_info.locale) + combined_info.name = ( + project_info.name if project_info.name else default_locale_info.name ) - project_info_dto.short_description = ( - self.short_description - if self.short_description - else default_locale.short_description + combined_info.description = ( + project_info.description + if project_info.description + else default_locale_info.description ) - project_info_dto.instructions = ( - self.instructions if self.instructions else default_locale.instructions + combined_info.short_description = ( + project_info.short_description + if project_info.short_description + else default_locale_info.short_description ) - project_info_dto.per_task_instructions = ( - self.per_task_instructions - if self.per_task_instructions - else default_locale.per_task_instructions + combined_info.instructions = ( + project_info.instructions + if project_info.instructions + else default_locale_info.instructions ) + combined_info.per_task_instructions = ( + project_info.per_task_instructions + if project_info.per_task_instructions + else default_locale_info.per_task_instructions + ) + return combined_info - return project_info_dto - - @staticmethod - def get_dto_for_all_locales(project_id) -> List[ProjectInfoDTO]: - locales = ProjectInfo.query.filter_by(project_id=project_id).all() + # Function to get a single ProjectInfoDTO + async def get_project_info_dto(locale_record) -> ProjectInfoDTO: + """ + Get DTO for the current ProjectInfo + :param locale_record: The record from the database for the locale + :param default_locale: The default locale DTO for any empty fields + :return: ProjectInfoDTO + """ + return ProjectInfoDTO( + locale=locale_record["locale"], + name=locale_record["name"] or "", + description=locale_record["description"] or "", + short_description=locale_record["short_description"] or "", + instructions=locale_record["instructions"] or "", + per_task_instructions=locale_record["per_task_instructions"] or "", + ) - project_info_dtos = [] - for locale in locales: - project_info_dto = locale.get_dto() - project_info_dtos.append(project_info_dto) + # Function to get DTOs for all locales of a project + async def get_dto_for_all_locales( + db: Database, project_id: int + ) -> List[ProjectInfoDTO]: + """ + Get DTOs for all locales associated with a project + :param database: The database connection + :param project_id: The project ID to filter locales + :return: List of ProjectInfoDTO + """ + query = """ + SELECT locale, name, description, short_description, instructions, per_task_instructions + FROM project_info + WHERE project_id = :project_id + """ + locales = await db.fetch_all(query=query, values={"project_id": project_id}) + + project_info_dtos = ( + [ + await ProjectInfo.get_project_info_dto(locale_record) + for locale_record in locales + ] + if locales + else [] + ) return project_info_dtos diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index b8f8726179..5b99008b6b 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -1,86 +1,202 @@ -from backend import db -from backend.models.postgis.utils import timestamp +from datetime import datetime, timezone + +from databases import Database +from sqlalchemy import Column, DateTime, ForeignKey, Integer +from backend.db import Base from backend.models.dtos.project_partner_dto import ( - ProjectPartnershipDTO, ProjectPartnerAction, + ProjectPartnershipDTO, ) +from backend.models.postgis.utils import timestamp + +class ProjectPartnershipHistory(Base): + """Logs changes to the Project-Partnership links""" -class ProjectPartnershipHistory(db.Model): __tablename__ = "project_partnerships_history" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - partnership_id = db.Column( - db.Integer, - db.ForeignKey("project_partnerships.id", ondelete="SET NULL"), + id = Column(Integer, primary_key=True, autoincrement=True) + partnership_id = Column( + Integer, + ForeignKey("project_partnerships.id", ondelete="SET NULL"), nullable=True, index=True, ) - project_id = db.Column( - db.Integer, - db.ForeignKey("projects.id", ondelete="CASCADE"), + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, ) - partner_id = db.Column( - db.Integer, - db.ForeignKey("partners.id", ondelete="CASCADE"), + partner_id = Column( + Integer, + ForeignKey("partners.id", ondelete="CASCADE"), nullable=False, index=True, ) - action = db.Column(db.Integer, default=ProjectPartnerAction.CREATE.value) - action_date = db.Column(db.DateTime, nullable=False, default=timestamp) - - started_on_old = db.Column(db.DateTime) - ended_on_old = db.Column(db.DateTime) - started_on_new = db.Column(db.DateTime) - ended_on_new = db.Column(db.DateTime) + action = Column(Integer, nullable=False, default=ProjectPartnerAction.CREATE.value) + action_date = Column(DateTime, nullable=False, default=timestamp) + + started_on_old = Column(DateTime, nullable=True) + ended_on_old = Column(DateTime, nullable=True) + started_on_new = Column(DateTime, nullable=True) + ended_on_new = Column(DateTime, nullable=True) + + def convert_to_utc_naive(self, dt: datetime) -> datetime: + """Converts a timezone-aware datetime to a UTC timezone-naive datetime.""" + if dt.tzinfo is not None: + # return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + async def create(self, db: Database) -> int: + """ + Inserts the current object as a record in the database and returns its ID. + """ + + if self.started_on_old: + self.started_on_old = self.convert_to_utc_naive(self.started_on_old) + if self.ended_on_old: + self.ended_on_old = self.convert_to_utc_naive(self.ended_on_old) + if self.started_on_new: + self.started_on_new = self.convert_to_utc_naive(self.started_on_new) + if self.ended_on_new: + self.ended_on_new = self.convert_to_utc_naive(self.ended_on_new) + + query = """ + INSERT INTO project_partnerships_history ( + partnership_id, + project_id, + partner_id, + action, + action_date, + started_on_old, + ended_on_old, + started_on_new, + ended_on_new + ) + VALUES ( + :partnership_id, + :project_id, + :partner_id, + :action, + :action_date, + :started_on_old, + :ended_on_old, + :started_on_new, + :ended_on_new + ) + RETURNING id + """ + values = { + "partnership_id": self.partnership_id, + "project_id": self.project_id, + "partner_id": self.partner_id, + "action": self.action if self.action else ProjectPartnerAction.CREATE.value, + "action_date": timestamp(), + "started_on_old": self.started_on_old if self.started_on_old else None, + "ended_on_old": self.ended_on_old if self.ended_on_old else None, + "started_on_new": self.started_on_new if self.started_on_new else None, + "ended_on_new": self.ended_on_new if self.ended_on_new else None, + } + result = await db.fetch_one(query, values=values) + return result["id"] + + +class ProjectPartnership(Base): + """Describes the relationship between a Project and a Partner""" - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - """Save changes to db""" - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - - -class ProjectPartnership(db.Model): __tablename__ = "project_partnerships" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE")) - partner_id = db.Column(db.Integer, db.ForeignKey("partners.id", ondelete="CASCADE")) - started_on = db.Column(db.DateTime, default=timestamp, nullable=False) - ended_on = db.Column(db.DateTime, nullable=True) + id = Column(Integer, primary_key=True, autoincrement=True) + project_id = Column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + partner_id = Column( + Integer, ForeignKey("partners.id", ondelete="CASCADE"), nullable=False + ) + started_on = Column(DateTime, nullable=False, default=timestamp) + ended_on = Column(DateTime, nullable=True) + + def convert_to_utc_naive(self, dt: datetime) -> datetime: + """Converts a timezone-aware datetime to a UTC timezone-naive datetime.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt @staticmethod - def get_by_id(partnership_id: int): + async def get_by_id(partnership_id: int, db: Database): """Return the user for the specified id, or None if not found""" - return db.session.get(ProjectPartnership, partnership_id) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - return self.id - - def save(self): - """Save changes to db""" - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + query = """ + SELECT * + FROM project_partnerships + WHERE id = :partnership_id + """ + result = await db.fetch_one(query, values={"partnership_id": partnership_id}) + return result if result else None + + async def create(self, db: Database) -> int: + """ + Inserts the current object as a record in the database and returns its ID. + """ + + self.started_on = self.convert_to_utc_naive(self.started_on) + self.ended_on = ( + self.convert_to_utc_naive(self.ended_on) if self.ended_on else None + ) + + query = """ + INSERT INTO project_partnerships (project_id, partner_id, started_on, ended_on) + VALUES (:project_id, :partner_id, :started_on, :ended_on) + RETURNING id + """ + values = { + "project_id": self.project_id, + "partner_id": self.partner_id, + "started_on": self.started_on, + "ended_on": self.ended_on if self.ended_on else None, + } + result = await db.fetch_one(query, values=values) + return result["id"] + + async def save(self, db: Database) -> None: + """ + Updates the current object in the database. + """ + self.started_on = self.convert_to_utc_naive(self.started_on) + self.ended_on = ( + self.convert_to_utc_naive(self.ended_on) if self.ended_on else None + ) + + query = """ + UPDATE project_partnerships + SET + project_id = :project_id, + partner_id = :partner_id, + started_on = :started_on, + ended_on = :ended_on + WHERE id = :id + """ + values = { + "id": self.id, + "project_id": self.project_id, + "partner_id": self.partner_id, + "started_on": self.started_on, + "ended_on": self.ended_on if self.ended_on else None, + } + await db.execute(query, values=values) + + async def delete(self, db: Database) -> None: + """ + Deletes the current object from the database. + """ + query = """ + DELETE FROM project_partnerships + WHERE id = :id + """ + await db.execute(query, values={"id": self.id}) def as_dto(self) -> ProjectPartnershipDTO: """Creates a Partnership DTO""" diff --git a/backend/models/postgis/release_version.py b/backend/models/postgis/release_version.py index 6c5bd99af2..69e02b1064 100644 --- a/backend/models/postgis/release_version.py +++ b/backend/models/postgis/release_version.py @@ -1,20 +1,24 @@ -from backend import db +from databases import Database +from sqlalchemy import Column, DateTime, String, insert +from backend.db import Base -class ReleaseVersion(db.Model): + +class ReleaseVersion(Base): """Describes an current release version of TM (i.e. github.com/hotosm/tasking-manager)""" __tablename__ = "release_version" - tag_name = db.Column(db.String(64), nullable=False, primary_key=True) - published_at = db.Column(db.DateTime, nullable=False) - - def update(self): - db.session.commit() + tag_name = Column(String(64), nullable=False, primary_key=True) + published_at = Column(DateTime, nullable=False) - def save(self): - db.session.add(self) - db.session.commit() + async def save(self, db: Database): + query = insert(ReleaseVersion.__table__).values( + tag_name=self.tag_name, published_at=self.published_at + ) + await db.execute(query) @staticmethod - def get(): - return ReleaseVersion.query.first() + async def get(db: Database): + """Get the latest release version""" + query = """SELECT * FROM release_version LIMIT 1""" + return await db.fetch_one(query=query) diff --git a/backend/models/postgis/tags.py b/backend/models/postgis/tags.py index 7d4d6a0b44..c43b422440 100644 --- a/backend/models/postgis/tags.py +++ b/backend/models/postgis/tags.py @@ -1,62 +1,87 @@ -from backend import db +from databases import Database +from sqlalchemy import Column, Integer, String + +from backend.db import Base from backend.models.dtos.tags_dto import TagsDTO -class Tags(db.Model): +class Tags(Base): """Describes an individual mapping Task""" __tablename__ = "tags" - id = db.Column(db.Integer, primary_key=True) - organisations = db.Column(db.String, unique=True) - campaigns = db.Column(db.String, unique=True) + id = Column(Integer, primary_key=True) + organisations = Column(String, unique=True) + campaigns = Column(String, unique=True) @staticmethod - def upsert_organisation_tag(organisation_tag: str) -> str: - """Insert organisation tag if it doesn't exists otherwise return matching tag""" - org_tag = Tags.query.filter_by(organisations=organisation_tag).one_or_none() - - if org_tag is not None: - return org_tag.organisations - - tag = Tags() - tag.organisations = organisation_tag - db.session.add( - tag - ) # Note no commit here, done as part of project update transaction + async def upsert_organisation_tag(organisation_tag: str, db: Database) -> str: + """Insert organisation tag if it doesn't exist otherwise return matching tag""" + # Try to find existing tag + query = """ + SELECT organisations FROM tags + WHERE organisations = :tag + LIMIT 1 + """ + existing_tag = await db.fetch_one(query=query, values={"tag": organisation_tag}) + + if existing_tag: + return existing_tag["organisations"] + + # Insert new tag + query = """ + INSERT INTO tags (organisations) + VALUES (:tag) + RETURNING organisations + """ + await db.execute(query=query, values={"tag": organisation_tag}) return organisation_tag @staticmethod - def upsert_campaign_tag(campaign_tag: str) -> str: + async def upsert_campaign_tag(campaign_tag: str, db: Database) -> str: """Insert campaign tag if doesn't exist otherwise return matching tag""" - camp_tag = Tags.query.filter_by(campaigns=campaign_tag).one_or_none() + # Try to find existing tag + query = """ + SELECT campaigns FROM tags + WHERE campaigns = :tag + LIMIT 1 + """ + existing_tag = await db.fetch_one(query=query, values={"tag": campaign_tag}) - if camp_tag is not None: - return camp_tag.campaigns + if existing_tag: + return existing_tag["campaigns"] - tag = Tags() - tag.campaigns = campaign_tag - db.session.add( - tag - ) # Note no commit here, done as part of project update transaction + # Insert new tag + query = """ + INSERT INTO tags (campaigns) + VALUES (:tag) + RETURNING campaigns + """ + await db.execute(query=query, values={"tag": campaign_tag}) return campaign_tag @staticmethod - def get_all_organisations(): + async def get_all_organisations(db: Database) -> TagsDTO: """Get all org tags in DB""" - result = db.session.query(Tags.organisations).filter( - Tags.organisations.isnot(None) - ) + query = """ + SELECT organisations FROM tags + WHERE organisations IS NOT NULL + """ + results = await db.fetch_all(query=query) dto = TagsDTO() - dto.tags = [r for (r,) in result] + dto.tags = [row["organisations"] for row in results] return dto @staticmethod - def get_all_campaigns(): + async def get_all_campaigns(db: Database) -> TagsDTO: """Get all campaign tags in DB""" - result = db.session.query(Tags.campaigns).filter(Tags.campaigns.isnot(None)) + query = """ + SELECT campaigns FROM tags + WHERE campaigns IS NOT NULL + """ + results = await db.fetch_all(query=query) dto = TagsDTO() - dto.tags = [r for (r,) in result] + dto.tags = [row["campaigns"] for row in results] return dto diff --git a/backend/models/postgis/task.py b/backend/models/postgis/task.py index 6708dde2ee..db208ff264 100644 --- a/backend/models/postgis/task.py +++ b/backend/models/postgis/task.py @@ -1,37 +1,53 @@ -import bleach import datetime -import geojson import json +from datetime import timezone from enum import Enum -from flask import current_app -from sqlalchemy.types import Float, Text -from sqlalchemy import desc, cast, func, distinct -from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound -from sqlalchemy.orm.session import make_transient +from typing import Any, Dict, List, Optional + +import bleach +import geojson +from databases import Database from geoalchemy2 import Geometry -from typing import List +from shapely.geometry import shape + +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + String, + Unicode, + desc, + select, +) +from sqlalchemy.orm import relationship +from sqlalchemy.orm.exc import MultipleResultsFound -from backend import db +from backend.config import settings +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.mapping_dto import TaskDTO, TaskHistoryDTO -from backend.models.dtos.validator_dto import MappedTasksByUser, MappedTasks +from backend.models.dtos.mapping_issues_dto import TaskMappingIssueDTO from backend.models.dtos.project_dto import ( + LockedTasksForUser, ProjectComment, ProjectCommentsDTO, - LockedTasksForUser, ) -from backend.models.dtos.mapping_issues_dto import TaskMappingIssueDTO -from backend.models.postgis.statuses import TaskStatus, MappingLevel +from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO +from backend.models.dtos.validator_dto import MappedTasks, MappedTasksByUser +from backend.models.postgis.statuses import MappingLevel, TaskStatus +from backend.models.postgis.task_annotation import TaskAnnotation from backend.models.postgis.user import User from backend.models.postgis.utils import ( InvalidData, InvalidGeoJson, - ST_GeomFromGeoJSON, - ST_SetSRID, - timestamp, parse_duration, + timestamp, ) -from backend.models.postgis.task_annotation import TaskAnnotation class TaskAction(Enum): @@ -47,42 +63,36 @@ class TaskAction(Enum): EXTENDED_FOR_VALIDATION = 8 -class TaskInvalidationHistory(db.Model): +class TaskInvalidationHistory(Base): """Describes the most recent history of task invalidation and subsequent validation""" __tablename__ = "task_invalidation_history" - id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False) - task_id = db.Column(db.Integer, nullable=False) - is_closed = db.Column(db.Boolean, default=False) - mapper_id = db.Column(db.BigInteger, db.ForeignKey("users.id", name="fk_mappers")) - mapped_date = db.Column(db.DateTime) - invalidator_id = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_invalidators") - ) - invalidated_date = db.Column(db.DateTime) - invalidation_history_id = db.Column( - db.Integer, db.ForeignKey("task_history.id", name="fk_invalidation_history") - ) - validator_id = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_validators") + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + task_id = Column(Integer, nullable=False) + is_closed = Column(Boolean, default=False) + mapper_id = Column(BigInteger, ForeignKey("users.id", name="fk_mappers")) + mapped_date = Column(DateTime) + invalidator_id = Column(BigInteger, ForeignKey("users.id", name="fk_invalidators")) + invalidated_date = Column(DateTime) + invalidation_history_id = Column( + Integer, ForeignKey("task_history.id", name="fk_invalidation_history") ) - validated_date = db.Column(db.DateTime) - updated_date = db.Column(db.DateTime, default=timestamp) + validator_id = Column(BigInteger, ForeignKey("users.id", name="fk_validators")) + validated_date = Column(DateTime) + updated_date = Column(DateTime, default=timestamp) __table_args__ = ( - db.ForeignKeyConstraint( + ForeignKeyConstraint( [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" ), - db.Index("idx_task_validation_history_composite", "task_id", "project_id"), - db.Index( + Index("idx_task_validation_history_composite", "task_id", "project_id"), + Index( "idx_task_validation_validator_status_composite", "invalidator_id", "is_closed", ), - db.Index( - "idx_task_validation_mapper_status_composite", "mapper_id", "is_closed" - ), + Index("idx_task_validation_mapper_status_composite", "mapper_id", "is_closed"), {}, ) @@ -91,13 +101,8 @@ def __init__(self, project_id, task_id): self.task_id = task_id self.is_closed = False - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - @staticmethod - def get_open_for_task(project_id, task_id, local_session=None): + async def get_open_for_task(project_id: int, task_id: int, db: Database): """ Retrieve the open TaskInvalidationHistory entry for the given project and task. @@ -120,135 +125,164 @@ def get_open_for_task(project_id, task_id, local_session=None): None: This method handles the MultipleResultsFound exception internally. """ try: - if local_session: - return ( - local_session.query(TaskInvalidationHistory) - .filter_by(task_id=task_id, project_id=project_id, is_closed=False) - .one_or_none() - ) - return TaskInvalidationHistory.query.filter_by( - task_id=task_id, project_id=project_id, is_closed=False - ).one_or_none() + # Fetch open entry + query = """ + SELECT * FROM task_invalidation_history + WHERE task_id = :task_id + AND project_id = :project_id + AND is_closed = FALSE + """ + entry = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} + ) + return entry except MultipleResultsFound: - TaskInvalidationHistory.close_duplicate_invalidation_history_rows( - project_id, task_id, local_session + await TaskInvalidationHistory.close_duplicate_invalidation_history_rows( + project_id, task_id, db ) - - return TaskInvalidationHistory.get_open_for_task( - project_id, task_id, local_session + return await TaskInvalidationHistory.get_open_for_task( + project_id, task_id, db ) @staticmethod - def close_duplicate_invalidation_history_rows( - project_id: int, task_id: int, local_session=None + async def close_duplicate_invalidation_history_rows( + project_id: int, task_id: int, db: Database ): """ Closes duplicate TaskInvalidationHistory entries except for the latest one for the given project and task. + """ - Args: - project_id (int): The ID of the project. - task_id (int): The ID of the task. - local_session (Session, optional): The SQLAlchemy session to use for the query. - If not provided, a default session is used. + # Fetch the oldest duplicate + query = """ + SELECT id FROM task_invalidation_history + WHERE task_id = :task_id + AND project_id = :project_id + AND is_closed = FALSE + ORDER BY id ASC + LIMIT 1 """ - if local_session: - oldest_dupe = ( - local_session.query(TaskInvalidationHistory) - .filter_by(task_id=task_id, project_id=project_id, is_closed=False) - .order_by(TaskInvalidationHistory.id.asc()) - .first() - ) - else: - oldest_dupe = ( - TaskInvalidationHistory.query.filter_by( - task_id=task_id, project_id=project_id, is_closed=False - ) - .order_by(TaskInvalidationHistory.id.asc()) - .first() - ) + oldest_dupe = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} + ) if oldest_dupe: - oldest_dupe.is_closed = True - if local_session: - local_session.commit() - else: - db.session.commit() + update_query = """ + UPDATE task_invalidation_history + SET is_closed = TRUE + WHERE id = :id + """ + await db.execute(query=update_query, values={"id": oldest_dupe["id"]}) @staticmethod - def close_all_for_task(project_id, task_id, local_session=None): - if local_session: - return ( - local_session.query(TaskInvalidationHistory) - .filter_by(task_id=task_id, project_id=project_id, is_closed=False) - .update({"is_closed": True}) - ) - TaskInvalidationHistory.query.filter_by( - task_id=task_id, project_id=project_id, is_closed=False - ).update({"is_closed": True}) + async def close_all_for_task(project_id: int, task_id: int, db: Database): + """ + Closes all open invalidation history entries for the specified task. + """ + update_query = """ + UPDATE task_invalidation_history + SET is_closed = TRUE, updated_date = :updated_date + WHERE project_id = :project_id AND task_id = :task_id AND is_closed = FALSE + """ + values = { + "project_id": project_id, + "task_id": task_id, + "updated_date": datetime.datetime.utcnow(), + } + + await db.execute(query=update_query, values=values) @staticmethod - def record_invalidation( - project_id, task_id, invalidator_id, history, local_session=None + async def record_invalidation( + project_id: int, task_id: int, invalidator_id: int, history, db: Database ): # Invalidation always kicks off a new entry for a task, so close any existing ones. - TaskInvalidationHistory.close_all_for_task( - project_id, task_id, local_session=local_session - ) + await TaskInvalidationHistory.close_all_for_task(project_id, task_id, db) - last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id) - if last_mapped is None: + last_mapped = await TaskHistory.get_last_mapped_action(project_id, task_id, db) + if not last_mapped: return - entry = TaskInvalidationHistory(project_id, task_id) - entry.invalidation_history_id = history.id - entry.mapper_id = last_mapped.user_id - entry.mapped_date = last_mapped.action_date - entry.invalidator_id = invalidator_id - entry.invalidated_date = history.action_date - entry.updated_date = timestamp() - if local_session: - local_session.add(entry) - else: - db.session.add(entry) + # Insert a new TaskInvalidationHistory entry + insert_query = """ + INSERT INTO task_invalidation_history ( + project_id, task_id, invalidation_history_id, mapper_id, mapped_date, + invalidator_id, invalidated_date, updated_date + ) + VALUES ( + :project_id, :task_id, :invalidation_history_id, :mapper_id, :mapped_date, + :invalidator_id, :invalidated_date, :updated_date + ) + """ + values = { + "project_id": project_id, + "task_id": task_id, + "invalidation_history_id": history.id, + "mapper_id": last_mapped["user_id"], + "mapped_date": last_mapped["action_date"], + "invalidator_id": invalidator_id, + "invalidated_date": history.action_date, + "updated_date": datetime.datetime.utcnow(), + } + + await db.execute(query=insert_query, values=values) @staticmethod - def record_validation( - project_id, task_id, validator_id, history, local_session=None + async def record_validation( + project_id: int, + task_id: int, + validator_id: int, + history: TaskHistoryDTO, + db: Database, ): - entry = TaskInvalidationHistory.get_open_for_task( - project_id, task_id, local_session=local_session - ) + entry = await TaskInvalidationHistory.get_open_for_task(project_id, task_id, db) # If no open invalidation to update, then nothing to do if entry is None: return - last_mapped = TaskHistory.get_last_mapped_action(project_id, task_id) - entry.mapper_id = last_mapped.user_id - entry.mapped_date = last_mapped.action_date - entry.validator_id = validator_id - entry.validated_date = history.action_date - entry.is_closed = True - entry.updated_date = timestamp() + last_mapped = await TaskHistory.get_last_mapped_action(project_id, task_id, db) + + # Update entry with validation details + update_query = """ + UPDATE task_invalidation_history + SET mapper_id = :mapper_id, + mapped_date = :mapped_date, + validator_id = :validator_id, + validated_date = :validated_date, + is_closed = TRUE, + updated_date = :updated_date + WHERE id = :entry_id + """ + await db.execute( + query=update_query, + values={ + "mapper_id": last_mapped["user_id"], + "mapped_date": last_mapped["action_date"], + "validator_id": validator_id, + "validated_date": history.action_date, + "updated_date": timestamp(), + "entry_id": entry["id"], + }, + ) -class TaskMappingIssue(db.Model): +class TaskMappingIssue(Base): """Describes an issue (along with an occurrence count) with a task mapping that contributed to invalidation of the task""" __tablename__ = "task_mapping_issues" - id = db.Column(db.Integer, primary_key=True) - task_history_id = db.Column( - db.Integer, db.ForeignKey("task_history.id"), nullable=False, index=True + id = Column(Integer, primary_key=True) + task_history_id = Column( + Integer, ForeignKey("task_history.id"), nullable=False, index=True ) - issue = db.Column(db.String, nullable=False) - mapping_issue_category_id = db.Column( - db.Integer, - db.ForeignKey("mapping_issue_categories.id", name="fk_issue_category"), + issue = Column(String, nullable=False) + mapping_issue_category_id = Column( + Integer, + ForeignKey("mapping_issue_categories.id", name="fk_issue_category"), nullable=False, ) - count = db.Column(db.Integer, nullable=False) + count = Column(Integer, nullable=False) def __init__(self, issue, count, mapping_issue_category_id, task_history_id=None): self.task_history_id = task_history_id @@ -256,11 +290,6 @@ def __init__(self, issue, count, mapping_issue_category_id, task_history_id=None self.count = count self.mapping_issue_category_id = mapping_issue_category_id - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - def as_dto(self): issue_dto = TaskMappingIssueDTO() issue_dto.category_id = self.mapping_issue_category_id @@ -272,36 +301,36 @@ def __repr__(self): return "{0}: {1}".format(self.issue, self.count) -class TaskHistory(db.Model): +class TaskHistory(Base): """Describes the history associated with a task""" __tablename__ = "task_history" - id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), index=True) - task_id = db.Column(db.Integer, nullable=False) - action = db.Column(db.String, nullable=False) - action_text = db.Column(db.String) - action_date = db.Column(db.DateTime, nullable=False, default=timestamp) - user_id = db.Column( - db.BigInteger, - db.ForeignKey("users.id", name="fk_users"), + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), index=True) + task_id = Column(Integer, nullable=False) + action = Column(String, nullable=False) + action_text = Column(String) + action_date = Column(DateTime, nullable=False, default=timestamp) + user_id = Column( + BigInteger, + ForeignKey("users.id", name="fk_users"), index=True, nullable=False, ) - invalidation_history = db.relationship( + invalidation_history = relationship( TaskInvalidationHistory, lazy="dynamic", cascade="all" ) - actioned_by = db.relationship(User) - task_mapping_issues = db.relationship(TaskMappingIssue, cascade="all") + actioned_by = relationship(User) + task_mapping_issues = relationship(TaskMappingIssue, cascade="all") __table_args__ = ( - db.ForeignKeyConstraint( + ForeignKeyConstraint( [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_tasks" ), - db.Index("idx_task_history_composite", "task_id", "project_id"), - db.Index("idx_task_history_project_id_user_id", "user_id", "project_id"), + Index("idx_task_history_composite", "task_id", "project_id"), + Index("idx_task_history_project_id_user_id", "user_id", "project_id"), {}, ) @@ -310,368 +339,403 @@ def __init__(self, task_id, project_id, user_id): self.project_id = project_id self.user_id = user_id - def set_task_extend_action(self, task_action: TaskAction): + def set_task_extend_action(task_action: TaskAction) -> str: if task_action not in [ TaskAction.EXTENDED_FOR_MAPPING, TaskAction.EXTENDED_FOR_VALIDATION, ]: raise ValueError("Invalid Action") + return task_action.name, None - self.action = task_action.name - - def set_task_locked_action(self, task_action: TaskAction): + def set_task_locked_action(task_action: TaskAction) -> str: if task_action not in [ TaskAction.LOCKED_FOR_MAPPING, TaskAction.LOCKED_FOR_VALIDATION, ]: raise ValueError("Invalid Action") + return task_action.name, None - self.action = task_action.name - - def set_comment_action(self, comment): - self.action = TaskAction.COMMENT.name - clean_comment = bleach.clean( - comment - ) # Bleach input to ensure no nefarious script tags etc - self.action_text = clean_comment - - def set_state_change_action(self, new_state): - self.action = TaskAction.STATE_CHANGE.name - self.action_text = new_state.name + def set_comment_action(comment: str) -> str: + clean_comment = bleach.clean(comment) # Ensure no harmful scripts or tags + return TaskAction.COMMENT.name, clean_comment - def set_auto_unlock_action(self, task_action: TaskAction): - self.action = task_action.name + def set_state_change_action(new_state: TaskStatus) -> str: + return TaskAction.STATE_CHANGE.name, new_state.name - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + def set_auto_unlock_action(task_action: TaskAction) -> str: + return task_action.name, None - @staticmethod - def update_task_locked_with_duration( - task_id: int, project_id: int, lock_action, user_id: int, local_session=None + async def update_task_locked_with_duration( + task_id: int, + project_id: int, + lock_action: TaskAction, + user_id: int, + db: Database, ): """ - Calculates the duration a task was locked for and sets it on the history record + Calculates the duration a task was locked for and sets it on the history record. :param task_id: Task in scope :param project_id: Project ID in scope :param lock_action: The lock action, either Mapping or Validation - :param user_id: Logged in user updating the task - :return: + :param user_id: Logged-in user updating the task. """ try: - if local_session: - last_locked = ( - local_session.query(TaskHistory) - .filter_by( - task_id=task_id, - project_id=project_id, - action=lock_action.name, - action_text=None, - user_id=user_id, - ) - .one() - ) - else: - last_locked = TaskHistory.query.filter_by( - task_id=task_id, - project_id=project_id, - action=lock_action.name, - action_text=None, - user_id=user_id, - ).one() - except NoResultFound: - # We suspect there's some kind or race condition that is occasionally deleting history records - # prior to user unlocking task. Most likely stemming from auto-unlock feature. However, given that - # we're trying to update a row that doesn't exist, it's better to return without doing anything - # rather than showing the user an error that they can't fix - return + # Fetch the last locked task history entry with raw SQL + query = """ + SELECT id, action_date + FROM task_history + WHERE task_id = :task_id + AND project_id = :project_id + AND action = :action + AND action_text IS NULL + AND user_id = :user_id + ORDER BY action_date DESC + LIMIT 1 + """ + values = { + "task_id": task_id, + "project_id": project_id, + "action": lock_action.name, + "user_id": user_id, + } + + last_locked = await db.fetch_one(query=query, values=values) + + if last_locked is None: + # We suspect there's some kind or race condition that is occasionally deleting history records + # prior to user unlocking task. Most likely stemming from auto-unlock feature. However, given that + # we're trying to update a row that doesn't exist, it's better to return without doing anything + # rather than showing the user an error that they can't fix. + # No record found, possibly a race condition or auto-unlock scenario. + return + + # Calculate the duration the task was locked for + duration_task_locked = ( + datetime.datetime.utcnow() - last_locked["action_date"] + ) + + # Cast duration to ISO format + action_text = ( + (datetime.datetime.min + duration_task_locked).time().isoformat() + ) + + # Update the task history with the duration + update_query = """ + UPDATE task_history + SET action_text = :action_text + WHERE id = :id + """ + update_values = { + "action_text": action_text, + "id": last_locked["id"], + } + await db.execute(query=update_query, values=update_values) + except MultipleResultsFound: # Again race conditions may mean we have multiple rows within the Task History. Here we attempt to # remove the oldest duplicate rows, and update the newest on the basis that this was the last action # the user was attempting to make. - TaskHistory.remove_duplicate_task_history_rows( - task_id, project_id, lock_action, user_id + # Handle race conditions by removing duplicates. + await TaskHistory.remove_duplicate_task_history_rows( + task_id, project_id, lock_action, user_id, db ) - # Now duplicate is removed, we recursively call ourself to update the duration on the remaining row - TaskHistory.update_task_locked_with_duration( - task_id, project_id, lock_action, user_id + # Recursively call the method to update the remaining row + await TaskHistory.update_task_locked_with_duration( + task_id, project_id, lock_action, user_id, db ) - return - duration_task_locked = datetime.datetime.utcnow() - last_locked.action_date - # Cast duration to isoformat for later transmission via api - last_locked.action_text = ( - (datetime.datetime.min + duration_task_locked).time().isoformat() - ) - if local_session: - local_session.commit() - else: - db.session.commit() - - @staticmethod - def remove_duplicate_task_history_rows( - task_id: int, project_id: int, lock_action: TaskStatus, user_id: int + async def remove_duplicate_task_history_rows( + task_id: int, + project_id: int, + lock_action: TaskAction, + user_id: int, + db: Database, ): - """Method used in rare cases where we have duplicate task history records for a given action by a user - This method will remove the oldest duplicate record, on the basis that the newest record was the - last action the user was attempting to perform - """ - dupe = ( - TaskHistory.query.filter( - TaskHistory.project_id == project_id, - TaskHistory.task_id == task_id, - TaskHistory.action == lock_action.name, - TaskHistory.user_id == user_id, + """ + Removes duplicate task history rows for the specified task, project, and action. + Keeps the most recent entry and deletes the older ones. + """ + duplicate_query = """ + DELETE FROM task_history + WHERE id IN ( + SELECT id + FROM task_history + WHERE task_id = :task_id + AND project_id = :project_id + AND action = :action + AND user_id = :user_id + ORDER BY action_date ASC + OFFSET 1 ) - .order_by(TaskHistory.id.asc()) - .first() - ) + """ + values = { + "task_id": task_id, + "project_id": project_id, + "action": lock_action.name, + "user_id": user_id, + } - dupe.delete() + await db.execute(query=duplicate_query, values=values) @staticmethod - def update_expired_and_locked_actions( - project_id: int, task_id: int, expiry_date: datetime, action_text: str + async def update_expired_and_locked_actions( + task_id: int, + project_id: int, + expiry_date: datetime, + action_text: str, + db: Database, ): - """ - Sets auto unlock state to all not finished actions, that are older then the expiry date. - Action is considered as a not finished, when it is in locked state and doesn't have action text - :param project_id: Project ID in scope - :param task_id: Task in scope - :param expiry_date: Action created before this date is treated as expired - :param action_text: Text which will be set for all changed actions - :return: - """ - all_expired = TaskHistory.query.filter( - TaskHistory.task_id == task_id, - TaskHistory.project_id == project_id, - TaskHistory.action_text.is_(None), - TaskHistory.action.in_( - [ - TaskAction.LOCKED_FOR_VALIDATION.name, - TaskAction.LOCKED_FOR_MAPPING.name, - TaskAction.EXTENDED_FOR_MAPPING.name, - TaskAction.EXTENDED_FOR_VALIDATION.name, - ] - ), - TaskHistory.action_date <= expiry_date, - ).all() - - for task_history in all_expired: - unlock_action = ( - TaskAction.AUTO_UNLOCKED_FOR_MAPPING - if task_history.action in ["LOCKED_FOR_MAPPING", "EXTENDED_FOR_MAPPING"] - else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION + """Update expired actions with an auto-unlock state.""" + query = """ + UPDATE task_history + SET action = CASE + WHEN action IN ('LOCKED_FOR_MAPPING', 'EXTENDED_FOR_MAPPING') + THEN 'AUTO_UNLOCKED_FOR_MAPPING' + WHEN action IN ('LOCKED_FOR_VALIDATION', 'EXTENDED_FOR_VALIDATION') + THEN 'AUTO_UNLOCKED_FOR_VALIDATION' + END, + action_text = :action_text + WHERE task_id = :task_id + AND project_id = :project_id + AND action_text IS NULL + AND action IN ( + 'LOCKED_FOR_MAPPING', 'LOCKED_FOR_VALIDATION', + 'EXTENDED_FOR_MAPPING', 'EXTENDED_FOR_VALIDATION' ) - - task_history.set_auto_unlock_action(unlock_action) - task_history.action_text = action_text - - db.session.commit() + AND action_date <= :expiry_date + """ + values = { + "action_text": action_text, + "task_id": task_id, + "project_id": project_id, + "expiry_date": expiry_date, + } + await db.execute(query=query, values=values) @staticmethod - def get_all_comments(project_id: int) -> ProjectCommentsDTO: + async def get_all_comments(project_id: int, db: Database) -> ProjectCommentsDTO: """Gets all comments for the supplied project_id""" - comments = ( - db.session.query( - TaskHistory.task_id, - TaskHistory.action_date, - TaskHistory.action_text, - User.username, - ) - .join(User) - .filter( - TaskHistory.project_id == project_id, - TaskHistory.action == TaskAction.COMMENT.name, - ) - .all() + # Raw SQL query joining task_history and users tables + query = """ + SELECT + th.task_id, + th.action_date, + th.action_text, + u.username + FROM + task_history th + JOIN + users u ON th.user_id = u.id + WHERE + th.project_id = :project_id + AND th.action = :action + """ + + # Execute the query with parameters + comments = await db.fetch_all( + query=query, + values={ + "project_id": project_id, + "action": "COMMENT", # Assuming TaskAction.COMMENT.name is "COMMENT" + }, ) + # Transform database results into DTOs comments_dto = ProjectCommentsDTO() for comment in comments: - dto = ProjectComment() - dto.comment = comment.action_text - dto.comment_date = comment.action_date - dto.user_name = comment.username - dto.task_id = comment.task_id + dto = ProjectComment( + comment=comment["action_text"], + comment_date=comment["action_date"], + user_name=comment["username"], + task_id=comment["task_id"], + ) comments_dto.comments.append(dto) return comments_dto @staticmethod - def get_last_status(project_id: int, task_id: int, for_undo: bool = False): - """Get the status the task was set to the last time the task had a STATUS_CHANGE""" - result = ( - db.session.query(TaskHistory.action_text) - .filter( - TaskHistory.project_id == project_id, - TaskHistory.task_id == task_id, - TaskHistory.action == TaskAction.STATE_CHANGE.name, - ) - .order_by(TaskHistory.action_date.desc()) - .all() + async def get_last_status( + project_id: int, task_id: int, db: Database, for_undo: bool = False + ) -> TaskStatus: + """Get the status the task was set to the last time the task had a STATUS_CHANGE.""" + + query = """ + SELECT action_text + FROM task_history + WHERE project_id = :project_id + AND task_id = :task_id + AND action = 'STATE_CHANGE' + ORDER BY action_date DESC + """ + result = await db.fetch_all( + query, values={"project_id": project_id, "task_id": task_id} ) + # If no results, return READY status if not result: - return TaskStatus.READY # No result so default to ready status + return TaskStatus.READY + # If we only have one result and for_undo is True, return READY if len(result) == 1 and for_undo: - # We're looking for the previous status, however, there isn't any so we'll return Ready return TaskStatus.READY - if for_undo and result[0][0] in [ + # If the last status was MAPPED or BADIMAGERY and for_undo is True, return READY + if for_undo and result[0]["action_text"] in [ TaskStatus.MAPPED.name, TaskStatus.BADIMAGERY.name, ]: - # We need to return a READY when last status of the task is badimagery or mapped. return TaskStatus.READY + # If for_undo is True, return the second last status if for_undo: - # Return the second last status which was status the task was previously set to - return TaskStatus[result[1][0]] - else: - return TaskStatus[result[0][0]] + return TaskStatus[result[1]["action_text"]] + # Otherwise, return the last status + return TaskStatus[result[0]["action_text"]] @staticmethod - def get_last_action(project_id: int, task_id: int): + async def get_last_action(project_id: int, task_id: int, db: Database): """Gets the most recent task history record for the task""" - return ( - TaskHistory.query.filter( - TaskHistory.project_id == project_id, TaskHistory.task_id == task_id - ) - .order_by(TaskHistory.action_date.desc()) - .first() - ) + query = """ + SELECT * FROM task_history + WHERE project_id = :project_id AND task_id = :task_id + ORDER BY action_date DESC + LIMIT 1 + """ + return await db.fetch_one(query, {"project_id": project_id, "task_id": task_id}) @staticmethod - def get_last_action_of_type( - project_id: int, task_id: int, allowed_task_actions: list + async def get_last_action_of_type( + project_id: int, task_id: int, allowed_task_actions: list, db: Database ): """Gets the most recent task history record having provided TaskAction""" - return ( - TaskHistory.query.filter( - TaskHistory.project_id == project_id, - TaskHistory.task_id == task_id, - TaskHistory.action.in_(allowed_task_actions), - ) - .order_by(TaskHistory.action_date.desc()) - .first() - ) + query = """ + SELECT id, action, action_date + FROM task_history + WHERE project_id = :project_id + AND task_id = :task_id + AND action = ANY(:allowed_actions) + ORDER BY action_date DESC + LIMIT 1 + """ + values = { + "project_id": project_id, + "task_id": task_id, + "allowed_actions": tuple(allowed_task_actions), + } + result = await db.fetch_one(query=query, values=values) + return result @staticmethod - def get_last_locked_action(project_id: int, task_id: int): + async def get_last_locked_action(project_id: int, task_id: int, db: Database): """Gets the most recent task history record with locked action for the task""" - return TaskHistory.get_last_action_of_type( + return await TaskHistory.get_last_action_of_type( project_id, task_id, [ TaskAction.LOCKED_FOR_MAPPING.name, TaskAction.LOCKED_FOR_VALIDATION.name, ], + db, ) @staticmethod - def get_last_locked_or_auto_unlocked_action(project_id: int, task_id: int): - """Gets the most recent task history record with locked or auto unlocked action for the task""" - return TaskHistory.get_last_action_of_type( - project_id, - task_id, - [ - TaskAction.LOCKED_FOR_MAPPING.name, - TaskAction.LOCKED_FOR_VALIDATION.name, - TaskAction.AUTO_UNLOCKED_FOR_MAPPING.name, - TaskAction.AUTO_UNLOCKED_FOR_VALIDATION.name, - ], + async def get_last_locked_or_auto_unlocked_action( + task_id: int, project_id: int, db: Database + ): + """Fetch the last locked or auto-unlocked action for a task.""" + query = """ + SELECT action + FROM task_history + WHERE task_id = :task_id + AND project_id = :project_id + AND action IN ( + 'LOCKED_FOR_MAPPING', + 'LOCKED_FOR_VALIDATION', + 'AUTO_UNLOCKED_FOR_MAPPING', + 'AUTO_UNLOCKED_FOR_VALIDATION' + ) + ORDER BY action_date DESC + LIMIT 1 + """ + row = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} ) + return row["action"] if row else None - def get_last_mapped_action(project_id: int, task_id: int): - """Gets the most recent mapped action, if any, in the task history""" - return ( - db.session.query(TaskHistory) - .filter( - TaskHistory.project_id == project_id, - TaskHistory.task_id == task_id, - TaskHistory.action == TaskAction.STATE_CHANGE.name, - TaskHistory.action_text.in_( - [TaskStatus.BADIMAGERY.name, TaskStatus.MAPPED.name] - ), - ) - .order_by(TaskHistory.action_date.desc()) - .first() + @staticmethod + async def get_last_mapped_action(project_id: int, task_id: int, db: Database): + """ + Gets the most recent mapped action, if any, in the task history. + """ + + query = """ + SELECT * FROM task_history + WHERE project_id = :project_id + AND task_id = :task_id + AND action = 'STATE_CHANGE' + AND action_text IN ('BADIMAGERY', 'MAPPED') + ORDER BY action_date DESC + LIMIT 1 + """ + last_mapped = await db.fetch_one( + query=query, values={"project_id": project_id, "task_id": task_id} ) + return last_mapped -class Task(db.Model): +class Task(Base): """Describes an individual mapping Task""" __tablename__ = "tasks" # Table has composite PK on (id and project_id) - id = db.Column(db.Integer, primary_key=True) - project_id = db.Column( - db.Integer, db.ForeignKey("projects.id"), index=True, primary_key=True + id = Column(Integer, primary_key=True) + project_id = Column( + Integer, ForeignKey("projects.id"), index=True, primary_key=True ) - x = db.Column(db.Integer) - y = db.Column(db.Integer) - zoom = db.Column(db.Integer) - extra_properties = db.Column(db.Unicode) + x = Column(Integer) + y = Column(Integer) + zoom = Column(Integer) + extra_properties = Column(Unicode) # Tasks need to be split differently if created from an arbitrary grid or were clipped to the edge of the AOI - is_square = db.Column(db.Boolean, default=True) - geometry = db.Column(Geometry("MULTIPOLYGON", srid=4326)) - task_status = db.Column(db.Integer, default=TaskStatus.READY.value) - locked_by = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users_locked"), index=True + is_square = Column(Boolean, default=True) + geometry = Column(Geometry("MULTIPOLYGON", srid=4326)) + task_status = Column(Integer, default=TaskStatus.READY.value) + locked_by = Column( + BigInteger, ForeignKey("users.id", name="fk_users_locked"), index=True ) - mapped_by = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users_mapper"), index=True + mapped_by = Column( + BigInteger, ForeignKey("users.id", name="fk_users_mapper"), index=True ) - validated_by = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users_validator"), index=True + validated_by = Column( + BigInteger, ForeignKey("users.id", name="fk_users_validator"), index=True ) # Mapped objects - task_history = db.relationship( + task_history = relationship( TaskHistory, cascade="all", order_by=desc(TaskHistory.action_date) ) - task_annotations = db.relationship(TaskAnnotation, cascade="all") - lock_holder = db.relationship(User, foreign_keys=[locked_by]) - mapper = db.relationship(User, foreign_keys=[mapped_by]) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def update(self, local_session=None): - """Updates the DB with the current state of the Task""" - if local_session: - local_session.commit() - else: - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + task_annotations = relationship(TaskAnnotation, cascade="all") + lock_holder = relationship(User, foreign_keys=[locked_by]) + mapper = relationship(User, foreign_keys=[mapped_by]) @classmethod def from_geojson_feature(cls, task_id, task_feature): """ - Constructs and validates a task from a GeoJson feature object - :param task_id: Unique ID for the task - :param task_feature: A geojson feature object + Constructs and validates a task from a GeoJson feature object. + :param task_id: Unique ID for the task. + :param task_feature: A geojson feature object. :raises InvalidGeoJson, InvalidData """ if type(task_feature) is not geojson.Feature: - raise InvalidGeoJson("MustBeFeature- Invalid GeoJson should be a feature") + raise InvalidGeoJson("MustBeFeature - Invalid GeoJson should be a feature") task_geometry = task_feature.geometry if type(task_geometry) is not geojson.MultiPolygon: - raise InvalidGeoJson("MustBeMultiPloygon- Geometry must be a MultiPolygon") + raise InvalidGeoJson("MustBeMultiPolygon - Geometry must be a MultiPolygon") if not task_geometry.is_valid: raise InvalidGeoJson( @@ -684,6 +748,7 @@ def from_geojson_feature(cls, task_id, task_feature): task.y = task_feature.properties["y"] task.zoom = task_feature.properties["zoom"] task.is_square = task_feature.properties["isSquare"] + task.geometry = shape(task_feature.geometry).wkt except KeyError as e: raise InvalidData( f"PropertyNotFound: Expected property not found: {str(e)}" @@ -693,392 +758,695 @@ def from_geojson_feature(cls, task_id, task_feature): task.extra_properties = json.dumps( task_feature.properties["extra_properties"] ) - task.id = task_id - task_geojson = geojson.dumps(task_geometry) - task.geometry = ST_SetSRID(ST_GeomFromGeoJSON(task_geojson), 4326) - return task @staticmethod - def get(task_id: int, project_id: int, local_session=None): - """ - Gets specified task - :param task_id: task ID in scope - :param project_id: project ID in scope - :return: Task if found otherwise None - """ - # LIKELY PROBLEM AREA - if local_session: - return ( - local_session.query(Task) - .filter_by(id=task_id, project_id=project_id) - .one_or_none() - ) - return Task.query.filter_by(id=task_id, project_id=project_id).one_or_none() + async def get(task_id: int, project_id: int, db: Database) -> Optional[dict]: + """ + Gets the specified task. + :param db: The async database connection. + :param task_id: Task ID in scope. + :param project_id: Project ID in scope. + :return: A dictionary representing the Task if found, otherwise None. + """ + query = """ + SELECT + id, project_id, x, y, zoom, is_square, task_status, locked_by, mapped_by, geometry + FROM + tasks + WHERE + id = :task_id AND project_id = :project_id + LIMIT 1 + """ + task = await db.fetch_one( + query, values={"task_id": task_id, "project_id": project_id} + ) + return task if task else None @staticmethod - def get_tasks(project_id: int, task_ids: List[int]): - """Get all tasks that match supplied list""" - return Task.query.filter( - Task.project_id == project_id, Task.id.in_(task_ids) - ).all() + async def exists(task_id: int, project_id: int, db: Database) -> bool: + """ + Checks if the specified task exists. + :param db: The async database connection. + :param task_id: Task ID in scope. + :param project_id: Project ID in scope. + :return: True if the task exists, otherwise False. + """ + query = """ + SELECT 1 + FROM tasks + WHERE id = :task_id AND project_id = :project_id + LIMIT 1 + """ + task = await db.fetch_one( + query, values={"task_id": task_id, "project_id": project_id} + ) + return task is not None @staticmethod - def get_all_tasks(project_id: int): - """Get all tasks for a given project""" - return Task.query.filter(Task.project_id == project_id).all() + async def get_tasks(project_id: int, task_ids: List[int], db: Database): + """ + Get all tasks that match the supplied list of task_ids for a project. + """ + query = """ + SELECT id, geometry + FROM tasks + WHERE project_id = :project_id + AND id = ANY(:task_ids) + """ + values = {"project_id": project_id, "task_ids": task_ids} + rows = await db.fetch_all(query=query, values=values) + return rows + + @staticmethod + async def get_all_tasks(project_id: int, db: Database): + """ + Get all tasks for a given project. + """ + query = """ + SELECT id, geometry + FROM tasks + WHERE project_id = :project_id + """ + values = {"project_id": project_id} + rows = await db.fetch_all(query=query, values=values) + return rows @staticmethod - def get_tasks_by_status(project_id: int, status: str): - "Returns all tasks filtered by status in a project" - return Task.query.filter( - Task.project_id == project_id, Task.task_status == TaskStatus[status].value - ).all() + async def get_tasks_by_status(project_id: int, status: str, db: Database): + """ + Returns all tasks filtered by status in a project. + :param project_id: The ID of the project. + :param status: The status to filter tasks by. + :param db: The database connection. + :return: A list of tasks with the specified status in the given project. + """ + query = """ + SELECT * + FROM tasks + WHERE project_id = :project_id + AND task_status = :task_status + """ + values = { + "project_id": project_id, + "task_status": TaskStatus[status].value, + } + tasks = await db.fetch_all(query=query, values=values) + return tasks @staticmethod - def auto_unlock_delta(): - return parse_duration(current_app.config["TASK_AUTOUNLOCK_AFTER"]) + async def auto_unlock_delta(): + return parse_duration(settings.TASK_AUTOUNLOCK_AFTER) @staticmethod - def auto_unlock_tasks(project_id: int): - """Unlock all tasks locked for longer than the auto-unlock delta""" - expiry_delta = Task.auto_unlock_delta() - lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat() + async def auto_unlock_tasks(project_id: int, db: Database): + """Unlock all tasks locked for longer than the auto-unlock delta.""" + expiry_delta = await Task.auto_unlock_delta() expiry_date = datetime.datetime.utcnow() - expiry_delta - old_tasks = ( - db.session.query(Task.id) - .filter(Task.id == TaskHistory.task_id) - .filter(Task.project_id == TaskHistory.project_id) - .filter(Task.task_status.in_([1, 3])) - .filter( - TaskHistory.action.in_( - [ - "EXTENDED_FOR_MAPPING", - "EXTENDED_FOR_VALIDATION", - "LOCKED_FOR_VALIDATION", - "LOCKED_FOR_MAPPING", - ] - ) - ) - .filter(TaskHistory.action_text.is_(None)) - .filter(Task.project_id == project_id) - .filter(TaskHistory.action_date <= str(expiry_date)) + # Query for task IDs to unlock + query = """ + SELECT tasks.id + FROM tasks + JOIN task_history + ON tasks.id = task_history.task_id + AND tasks.project_id = task_history.project_id + WHERE tasks.task_status IN (1, 3) + AND task_history.action IN ( + 'EXTENDED_FOR_MAPPING', + 'EXTENDED_FOR_VALIDATION', + 'LOCKED_FOR_VALIDATION', + 'LOCKED_FOR_MAPPING' + ) + AND task_history.action_text IS NULL + AND tasks.project_id = :project_id + AND task_history.action_date <= :expiry_date + """ + old_task_ids = await db.fetch_all( + query=query, values={"project_id": project_id, "expiry_date": expiry_date} ) + old_task_ids = [row["id"] for row in old_task_ids] + if not old_task_ids: + return # No tasks to unlock - if old_tasks.count() == 0: - # no tasks older than the delta found, return without further processing - return - - for old_task in old_tasks: - task = Task.get(old_task[0], project_id) - task.auto_unlock_expired_tasks(expiry_date, lock_duration) + for task_id in old_task_ids: + await Task.auto_unlock_expired_tasks(task_id, project_id, expiry_date, db) - def auto_unlock_expired_tasks(self, expiry_date, lock_duration): - """Unlock all tasks locked before expiry date. Clears task lock if needed""" - TaskHistory.update_expired_and_locked_actions( - self.project_id, self.id, expiry_date, lock_duration + @staticmethod + async def auto_unlock_expired_tasks( + task_id: int, project_id: int, expiry_date: datetime, db: Database + ): + """Unlock all tasks locked before expiry date. Clears task lock if needed.""" + lock_duration = ( + (datetime.datetime.min + await Task.auto_unlock_delta()).time().isoformat() ) - last_action = TaskHistory.get_last_locked_or_auto_unlocked_action( - self.project_id, self.id + await TaskHistory.update_expired_and_locked_actions( + task_id, project_id, expiry_date, lock_duration, db + ) + last_action = await TaskHistory.get_last_locked_or_auto_unlocked_action( + task_id, project_id, db ) - if last_action.action in [ - "AUTO_UNLOCKED_FOR_MAPPING", - "AUTO_UNLOCKED_FOR_VALIDATION", - ]: - self.clear_lock() - def is_mappable(self): - """Determines if task in scope is in suitable state for mapping""" - if TaskStatus(self.task_status) not in [ + if last_action in ["AUTO_UNLOCKED_FOR_MAPPING", "AUTO_UNLOCKED_FOR_VALIDATION"]: + await Task.clear_lock(task_id, project_id, db) + + @staticmethod + def is_mappable(task: dict) -> bool: + """Determines if task in scope is in a suitable state for mapping.""" + if TaskStatus(task.task_status) not in [ TaskStatus.READY, TaskStatus.INVALIDATED, ]: return False - return True - def set_task_history( - self, action, user_id, comment=None, new_state=None, mapping_issues=None + @staticmethod + async def set_task_history( + task_id: int, + project_id: int, + user_id: int, + action: TaskAction, + db: Database, + comment: Optional[str] = None, + new_state: Optional[TaskStatus] = None, + mapping_issues: Optional[ + List[Dict[str, Any]] + ] = None, # Updated to accept a list of dictionaries ): - """ - Sets the task history for the action that the user has just performed - :param task: Task in scope - :param user_id: ID of user performing the action - :param action: Action the user has performed - :param comment: Comment user has added - :param new_state: New state of the task - :param mapping_issues: Identified issues leading to invalidation - """ - history = TaskHistory(self.id, self.project_id, user_id) + """Sets the task history for the action that the user has just performed.""" + # Determine action and action_text based on the task action if action in [TaskAction.LOCKED_FOR_MAPPING, TaskAction.LOCKED_FOR_VALIDATION]: - history.set_task_locked_action(action) + action_name, action_text = TaskHistory.set_task_locked_action(action) elif action in [ TaskAction.EXTENDED_FOR_MAPPING, TaskAction.EXTENDED_FOR_VALIDATION, ]: - history.set_task_extend_action(action) + action_name, action_text = TaskHistory.set_task_extend_action(action) elif action == TaskAction.COMMENT: - history.set_comment_action(comment) - elif action == TaskAction.STATE_CHANGE: - history.set_state_change_action(new_state) + action_name, action_text = TaskHistory.set_comment_action(comment) + elif action == TaskAction.STATE_CHANGE and new_state: + action_name, action_text = TaskHistory.set_state_change_action(new_state) elif action in [ TaskAction.AUTO_UNLOCKED_FOR_MAPPING, TaskAction.AUTO_UNLOCKED_FOR_VALIDATION, ]: - history.set_auto_unlock_action(action) + action_name, action_text = TaskHistory.set_auto_unlock_action(action) + else: + raise ValueError("Invalid Action") + + # Insert the task history into the task_history table + query = """ + INSERT INTO task_history (task_id, user_id, project_id, action, action_text, action_date) + VALUES (:task_id, :user_id, :project_id, :action, :action_text, :action_date) + RETURNING id, action, action_text, action_date + """ + values = { + "task_id": task_id, + "user_id": user_id, + "project_id": project_id, + "action": action_name, + "action_text": action_text, + "action_date": timestamp(), + } + task_history = await db.fetch_one(query=query, values=values) + + # TODO Verify this. + # Insert any mapping issues into the task_mapping_issues table, building the query dynamically + if mapping_issues: + for issue in mapping_issues: + fields = {"task_history_id": task_history["id"]} + placeholders = [":task_history_id"] + + if "issue" in issue: + fields["issue"] = issue["issue"] + placeholders.append(":issue") + + if "mapping_issue_category_id" in issue: + fields["mapping_issue_category_id"] = issue[ + "mapping_issue_category_id" + ] + placeholders.append(":mapping_issue_category_id") + + if "count" in issue: + fields["count"] = issue["count"] + placeholders.append(":count") + + columns = ", ".join(fields.keys()) + values_placeholders = ", ".join(placeholders) - if mapping_issues is not None: - history.task_mapping_issues = mapping_issues + mapping_issue_query = f""" + INSERT INTO task_mapping_issues ({columns}) + VALUES ({values_placeholders}) + """ - self.task_history.append(history) - return history + await db.execute(query=mapping_issue_query, values=fields) - def lock_task_for_mapping(self, user_id: int): - self.set_task_history(TaskAction.LOCKED_FOR_MAPPING, user_id) - self.task_status = TaskStatus.LOCKED_FOR_MAPPING.value - self.locked_by = user_id - self.update() + return task_history + + @staticmethod + async def lock_task_for_mapping( + task_id: int, project_id: int, user_id: int, db: Database + ): + """Locks a task for mapping by a user.""" + # Insert a task history record for the action + await Task.set_task_history( + task_id, project_id, user_id, TaskAction.LOCKED_FOR_MAPPING, db + ) - def lock_task_for_validating(self, user_id: int): - self.set_task_history(TaskAction.LOCKED_FOR_VALIDATION, user_id) - self.task_status = TaskStatus.LOCKED_FOR_VALIDATION.value - self.locked_by = user_id - self.update() + # Update the task's status and set it as locked by the user for the specific project_id + query = """ + UPDATE tasks + SET task_status = :task_status, locked_by = :user_id + WHERE id = :task_id AND project_id = :project_id + """ + values = { + "task_status": TaskStatus.LOCKED_FOR_MAPPING.value, + "user_id": user_id, + "task_id": task_id, + "project_id": project_id, + } + await db.execute(query=query, values=values) - def reset_task(self, user_id: int): - expiry_delta = Task.auto_unlock_delta() + @staticmethod + async def lock_task_for_validating( + task_id: int, project_id: int, user_id: int, db: Database + ): + """Lock the task for validation.""" + # Insert a task history record for the action + await Task.set_task_history( + task_id, project_id, user_id, TaskAction.LOCKED_FOR_VALIDATION, db + ) + query = """ + UPDATE tasks + SET task_status = :status, locked_by = :user_id + WHERE id = :task_id AND project_id = :project_id + """ + values = { + "status": TaskStatus.LOCKED_FOR_VALIDATION.value, + "user_id": user_id, + "task_id": task_id, + "project_id": project_id, + } + await db.execute(query=query, values=values) + + @staticmethod + async def reset_task(task_id: int, project_id: int, user_id: int, db: Database): + """Resets the task with the provided task_id and updates its status""" + + # Fetch the auto-unlock duration + expiry_delta = await Task.auto_unlock_delta() lock_duration = (datetime.datetime.min + expiry_delta).time().isoformat() - if TaskStatus(self.task_status) in [ + + query = """ + SELECT task_status, mapped_by, validated_by, locked_by + FROM tasks + WHERE id = :task_id AND project_id = :project_id + """ + task = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} + ) + + if TaskStatus(task["task_status"]) in [ TaskStatus.LOCKED_FOR_MAPPING, TaskStatus.LOCKED_FOR_VALIDATION, ]: - self.record_auto_unlock(lock_duration) - - self.set_task_history(TaskAction.STATE_CHANGE, user_id, None, TaskStatus.READY) - self.mapped_by = None - self.validated_by = None - self.locked_by = None - self.task_status = TaskStatus.READY.value - self.update() - - def clear_task_lock(self): - """ - Unlocks task in scope in the database. Clears the lock as though it never happened. - No history of the unlock is recorded. - :return: - """ - # clear the lock action for the task in the task history - last_action = TaskHistory.get_last_locked_action(self.project_id, self.id) - last_action.delete() - - # Set locked_by to null and status to last status on task - self.clear_lock() - - def record_auto_unlock(self, lock_duration): - locked_user = self.locked_by - last_action = TaskHistory.get_last_locked_action(self.project_id, self.id) - next_action = ( - TaskAction.AUTO_UNLOCKED_FOR_MAPPING - if last_action.action == "LOCKED_FOR_MAPPING" - else TaskAction.AUTO_UNLOCKED_FOR_VALIDATION + await Task.record_auto_unlock(task_id, project_id, lock_duration, db) + + update_task_query = """ + UPDATE tasks + SET task_status = :ready_status, + mapped_by = NULL, + validated_by = NULL, + locked_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_task_query, + values={ + "task_id": task_id, + "ready_status": TaskStatus.READY.value, + "project_id": project_id, + }, ) - self.clear_task_lock() + # Log the state change in the task history + await Task.set_task_history( + task_id=task_id, + project_id=None, # Assuming project_id is not needed here or is passed earlier + user_id=user_id, + action=TaskAction.STATE_CHANGE, + db=db, + new_state=TaskStatus.READY, + ) + + @staticmethod + async def clear_task_lock(task_id: int, project_id: int, db: Database): + """Unlocks task in scope, clears the lock as though it never happened.""" + + # Get the last locked action and delete it from the task history + last_action = await TaskHistory.get_last_locked_action(project_id, task_id, db) + if last_action: + delete_action_query = """ + DELETE FROM task_history + WHERE id = :history_id + """ + await db.execute( + query=delete_action_query, values={"history_id": last_action["id"]} + ) + + # Clear the lock from the task itself + await Task.clear_lock(task_id=task_id, project_id=project_id, db=db) + + @staticmethod + async def record_auto_unlock( + task_id: int, project_id: int, lock_duration: str, db: Database + ): + """Automatically unlocks the task and records the auto-unlock action in task history""" + + # Fetch the locked user and last locked action for the task + locked_user_query = """ + SELECT locked_by + FROM tasks + WHERE id = :task_id AND project_id = :project_id + """ + locked_user = await db.fetch_one( + query=locked_user_query, + values={"task_id": task_id, "project_id": project_id}, + ) + + last_action = await TaskHistory.get_last_locked_action(project_id, task_id, db) + + if last_action and last_action["action"] == "LOCKED_FOR_MAPPING": + next_action = TaskAction.AUTO_UNLOCKED_FOR_MAPPING + else: + next_action = TaskAction.AUTO_UNLOCKED_FOR_VALIDATION + + # Clear the task lock (clear the lock and delete the last locked action) + await Task.clear_task_lock(task_id, project_id, db) # Add AUTO_UNLOCKED action in the task history - auto_unlocked = self.set_task_history(action=next_action, user_id=locked_user) - auto_unlocked.action_text = lock_duration - self.update() + auto_unlocked = await Task.set_task_history( + task_id=task_id, + project_id=project_id, + user_id=locked_user["locked_by"], + action=next_action, + db=db, + ) - def unlock_task( - self, - user_id, - new_state=None, - comment=None, - undo=False, - issues=None, - local_session=None, + # Update the action_text with the lock duration + update_history_query = """ + UPDATE task_history + SET action_text = :lock_duration + WHERE id = :history_id + """ + await db.execute( + query=update_history_query, + values={"lock_duration": lock_duration, "history_id": auto_unlocked["id"]}, + ) + + @staticmethod + async def unlock_task( + task_id: int, + project_id: int, + user_id: int, + new_state: TaskStatus, + db: Database, + comment: Optional[str] = None, + undo: bool = False, + issues: Optional[List[Dict[str, Any]]] = None, ): - """Unlock task and ensure duration task locked is saved in History""" + """Unlock the task and change its state.""" + # Add task comment history if provided if comment: - self.set_task_history( - action=TaskAction.COMMENT, + await Task.set_task_history( + task_id, + project_id, + user_id, + TaskAction.COMMENT, + db, comment=comment, - user_id=user_id, mapping_issues=issues, ) - - history = self.set_task_history( - action=TaskAction.STATE_CHANGE, + # Record state change in history + history = await Task.set_task_history( + task_id, + project_id, + user_id, + TaskAction.STATE_CHANGE, + db, + comment=comment, new_state=new_state, - user_id=user_id, mapping_issues=issues, ) - # If undo, clear the mapped_by and validated_by fields if undo: if new_state == TaskStatus.MAPPED: - self.validated_by = None + update_query = """ + UPDATE tasks + SET validated_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_query, + values={"task_id": task_id, "project_id": project_id}, + ) elif new_state == TaskStatus.READY: - self.mapped_by = None - elif ( - new_state in [TaskStatus.MAPPED, TaskStatus.BADIMAGERY] - and TaskStatus(self.task_status) != TaskStatus.LOCKED_FOR_VALIDATION - ): - # Don't set mapped if state being set back to mapped after validation - self.mapped_by = user_id - elif new_state == TaskStatus.VALIDATED: - TaskInvalidationHistory.record_validation( - self.project_id, self.id, user_id, history, local_session=local_session - ) - self.validated_by = user_id - elif new_state == TaskStatus.INVALIDATED: - TaskInvalidationHistory.record_invalidation( - self.project_id, self.id, user_id, history, local_session=local_session + update_query = """ + UPDATE tasks + SET mapped_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_query, + values={"task_id": task_id, "project_id": project_id}, + ) + else: + current_status_query = """ + SELECT task_status FROM tasks WHERE id = :task_id AND project_id = :project_id + """ + current_status_result = await db.fetch_one( + query=current_status_query, + values={"task_id": task_id, "project_id": project_id}, ) - self.mapped_by = None - self.validated_by = None + current_status = TaskStatus(current_status_result["task_status"]) + # Handle specific state changes + if new_state == TaskStatus.VALIDATED: + await TaskInvalidationHistory.record_validation( + project_id, task_id, user_id, history, db + ) + update_query = """ + UPDATE tasks + SET validated_by = :user_id + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_query, + values={ + "user_id": user_id, + "task_id": task_id, + "project_id": project_id, + }, + ) + + elif new_state == TaskStatus.INVALIDATED: + await TaskInvalidationHistory.record_invalidation( + project_id, task_id, user_id, history, db + ) + update_query = """ + UPDATE tasks + SET mapped_by = NULL, validated_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_query, + values={"task_id": task_id, "project_id": project_id}, + ) + + # Set `mapped_by` for MAPPED or BADIMAGERY states when not locked for validation + elif new_state in [TaskStatus.MAPPED, TaskStatus.BADIMAGERY]: + if current_status != TaskStatus.LOCKED_FOR_VALIDATION: + update_query = """ + UPDATE tasks + SET mapped_by = :user_id + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=update_query, + values={ + "user_id": user_id, + "task_id": task_id, + "project_id": project_id, + }, + ) - if not undo: + # Update task locked duration in the history when `undo` is False # Using a slightly evil side effect of Actions and Statuses having the same name here :) - TaskHistory.update_task_locked_with_duration( - self.id, - self.project_id, - TaskStatus(self.task_status), - user_id, - local_session=local_session, + await TaskHistory.update_task_locked_with_duration( + task_id, project_id, TaskStatus(current_status), user_id, db ) + # Final query for updating task status + final_update_query = """ + UPDATE tasks + SET task_status = :new_status, locked_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + query=final_update_query, + values={ + "new_status": new_state.value, + "task_id": task_id, + "project_id": project_id, + }, + ) - self.task_status = new_state.value - self.locked_by = None - if local_session: - self.update(local_session=local_session) - else: - self.update() - - def reset_lock(self, user_id, comment=None): - """Removes a current lock from a task, resets to last status and - updates history with duration of lock""" + @staticmethod + async def reset_lock( + task_id: int, + project_id: int, + task_status: TaskStatus, + user_id: int, + comment: Optional[str], + db: Database, + ): + """ + Removes a current lock from a task, resets to the last status, and updates history with the lock duration. + :param task_id: The ID of the task to reset the lock for. + :param project_id: The project ID the task belongs to. + :param task_status: The current task status. + :param user_id: The ID of the user resetting the lock. + :param comment: Optional comment provided during the reset. + :param db: The database connection. + """ + # If a comment is provided, set the task history with a comment action if comment: - self.set_task_history( - action=TaskAction.COMMENT, comment=comment, user_id=user_id + await Task.set_task_history( + task_id=task_id, + project_id=project_id, + user_id=user_id, + action=TaskAction.COMMENT, + comment=comment, + db=db, ) - - # Using a slightly evil side effect of Actions and Statuses having the same name here :) - TaskHistory.update_task_locked_with_duration( - self.id, self.project_id, TaskStatus(self.task_status), user_id + # Update task lock history with duration + await TaskHistory.update_task_locked_with_duration( + task_id=task_id, + project_id=project_id, + lock_action=TaskStatus(task_status), + user_id=user_id, + db=db, ) - self.clear_lock() - def clear_lock(self): - """Resets to last status and removes current lock from a task""" - self.task_status = TaskHistory.get_last_status(self.project_id, self.id).value - self.locked_by = None - self.update() + # Clear the lock on the task + await Task.clear_lock(task_id=task_id, project_id=project_id, db=db) + + @staticmethod + async def clear_lock(task_id: int, project_id: int, db: Database): + """ + Resets the task to its last status and removes the current lock from the task. + :param task_id: The ID of the task to clear the lock for. + :param project_id: The project ID the task belongs to. + :param db: The database connection. + """ + last_status = await TaskHistory.get_last_status(project_id, task_id, db) + # Clear the lock by updating the task's status and lock status + update_query = """ + UPDATE tasks + SET task_status = :task_status, locked_by = NULL + WHERE id = :task_id AND project_id = :project_id + """ + update_values = { + "task_status": last_status.value, + "task_id": task_id, + "project_id": project_id, + } + await db.execute(query=update_query, values=update_values) @staticmethod - def get_tasks_as_geojson_feature_collection( - project_id, - task_ids_str: str = None, - order_by: str = None, + async def get_tasks_as_geojson_feature_collection( + db: Database, + project_id: int, + task_ids_str: Optional[str] = None, + order_by: Optional[str] = None, order_by_type: str = "ASC", - status: int = None, - ): + status: Optional[int] = None, + ) -> geojson.FeatureCollection: """ - Creates a geoJson.FeatureCollection object for tasks related to the supplied project ID + Creates a geoJson.FeatureCollection object for tasks related to the supplied project ID. + :param db: The async database connection :param project_id: Owning project ID - :order_by: sorting option: available values update_date and building_area_diff - :status: task status id to filter by + :param task_ids_str: Comma-separated task IDs to filter by + :param order_by: Sorting option: available values are 'effort_prediction' + :param order_by_type: Sorting order: 'ASC' or 'DESC' + :param status: Task status ID to filter by :return: geojson.FeatureCollection """ - # subquery = ( - # db.session.query(func.max(TaskHistory.action_date)) - # .filter( - # Task.id == TaskHistory.task_id, - # Task.project_id == TaskHistory.project_id, - # ) - # .correlate(Task) - # .group_by(Task.id) - # .label("update_date") - # ) - query = db.session.query( - Task.id, - Task.x, - Task.y, - Task.zoom, - Task.is_square, - Task.task_status, - Task.geometry.ST_AsGeoJSON().label("geojson"), - Task.locked_by, - Task.mapped_by, - # subquery, - ) + # Base query + query = """ + SELECT + t.id, + t.x, + t.y, + t.zoom, + t.is_square, + t.task_status, + ST_AsGeoJSON(t.geometry) AS geojson, + t.locked_by, + t.mapped_by + FROM tasks t + WHERE t.project_id = :project_id + """ - filters = [Task.project_id == project_id] + # Initialize query parameters + filters = {"project_id": project_id} + # Add task_id filter if task_ids_str: - task_ids = list(map(int, task_ids_str.split(","))) - tasks = Task.get_tasks(project_id, task_ids) - if not tasks or len(tasks) == 0: - raise NotFound( - sub_code="TASKS_NOT_FOUND", tasks=task_ids, project_id=project_id - ) - else: - tasks_filters = [task.id for task in tasks] - filters = [Task.project_id == project_id, Task.id.in_(tasks_filters)] - else: - tasks = Task.get_all_tasks(project_id) - if not tasks or len(tasks) == 0: - raise NotFound(sub_code="TASKS_NOT_FOUND", project_id=project_id) + task_ids = [int(task_id) for task_id in task_ids_str.split(",")] + query += " AND t.id = ANY(:task_ids)" + filters["task_ids"] = tuple(task_ids) - if status: - filters.append(Task.task_status == status) + # Add status filter + if status is not None: + query += " AND t.task_status = :status" + filters["status"] = status + # Add ordering if order_by == "effort_prediction": - query = query.outerjoin(TaskAnnotation).filter(*filters) if order_by_type == "DESC": - query = query.order_by( - desc( - cast( - cast(TaskAnnotation.properties["building_area_diff"], Text), - Float, - ) - ) - ) + query += """ + LEFT JOIN task_annotations ta ON ta.task_id = t.id + ORDER BY CAST(ta.properties->>'building_area_diff' AS FLOAT) DESC + """ else: - query = query.order_by( - cast( - cast(TaskAnnotation.properties["building_area_diff"], Text), - Float, - ) - ) - # elif order_by == "last_updated": - # if order_by_type == "DESC": - # query = query.filter(*filters).order_by(desc("update_date")) - # else: - # query = query.filter(*filters).order_by("update_date") - else: - query = query.filter(*filters) + query += """ + LEFT JOIN task_annotations ta ON ta.task_id = t.id + ORDER BY CAST(ta.properties->>'building_area_diff' AS FLOAT) ASC + """ + elif order_by: + if order_by_type == "DESC": + query += f" ORDER BY {order_by} DESC" + else: + query += f" ORDER BY {order_by} ASC" - project_tasks = query.all() + # Execute the query + rows = await db.fetch_all(query, values=filters) + # Process results into geojson.FeatureCollection tasks_features = [] - for task in project_tasks: - task_geometry = geojson.loads(task.geojson) + for row in rows: + task_geometry = geojson.loads(row["geojson"]) task_properties = dict( - taskId=task.id, - taskX=task.x, - taskY=task.y, - taskZoom=task.zoom, - taskIsSquare=task.is_square, - taskStatus=TaskStatus(task.task_status).name, - lockedBy=task.locked_by, - mappedBy=task.mapped_by, + taskId=row["id"], + taskX=row["x"], + taskY=row["y"], + taskZoom=row["zoom"], + taskIsSquare=row["is_square"], + taskStatus=TaskStatus(row["task_status"]).name, + lockedBy=row["locked_by"], + mappedBy=row["mapped_by"], ) - feature = geojson.Feature( geometry=task_geometry, properties=task_properties ) @@ -1087,214 +1455,473 @@ def get_tasks_as_geojson_feature_collection( return geojson.FeatureCollection(tasks_features) @staticmethod - def get_tasks_as_geojson_feature_collection_no_geom(project_id): + async def get_tasks_as_geojson_feature_collection_no_geom( + db: Database, project_id: int + ) -> geojson.FeatureCollection: """ - Creates a geoJson.FeatureCollection object for all tasks related to the supplied project ID without geometry + Creates a geoJson.FeatureCollection object for all tasks related to the supplied project ID without geometry. + :param db: The async database connection :param project_id: Owning project ID :return: geojson.FeatureCollection """ - project_tasks = ( - db.session.query( - Task.id, Task.x, Task.y, Task.zoom, Task.is_square, Task.task_status - ) - .filter(Task.project_id == project_id) - .all() - ) + query = """ + SELECT + t.id, + t.x, + t.y, + t.zoom, + t.is_square, + t.task_status + FROM tasks t + WHERE t.project_id = :project_id + """ + + rows = await db.fetch_all(query, values={"project_id": project_id}) tasks_features = [] - for task in project_tasks: + for row in rows: task_properties = dict( - taskId=task.id, - taskX=task.x, - taskY=task.y, - taskZoom=task.zoom, - taskIsSquare=task.is_square, - taskStatus=TaskStatus(task.task_status).name, + taskId=row["id"], + taskX=row["x"], + taskY=row["y"], + taskZoom=row["zoom"], + taskIsSquare=row["is_square"], + taskStatus=TaskStatus(row["task_status"]).name, ) - feature = geojson.Feature(properties=task_properties) tasks_features.append(feature) return geojson.FeatureCollection(tasks_features) @staticmethod - def get_mapped_tasks_by_user(project_id: int): + async def get_mapped_tasks_by_user(project_id: int, db: Database) -> MappedTasks: """Gets all mapped tasks for supplied project grouped by user""" - results = ( - db.session.query( - User.username, - User.mapping_level, - func.count(distinct(Task.id)), - func.json_agg(distinct(Task.id)), - func.max(TaskHistory.action_date), - User.date_registered, - User.last_validation_date, - ) - .filter(Task.project_id == TaskHistory.project_id) - .filter(Task.id == TaskHistory.task_id) - .filter(Task.mapped_by == User.id) - .filter(Task.project_id == project_id) - .filter(Task.task_status == 2) - .filter(TaskHistory.action_text == "MAPPED") - .group_by( - User.username, - User.mapping_level, - User.date_registered, - User.last_validation_date, - ) - ) + + # Modified query to return the JSON as text to handle manually + query = """ + SELECT + u.username, + u.mapping_level, + COUNT(DISTINCT t.id) as mapped_task_count, + JSONB_AGG(DISTINCT t.id)::text as tasks_mapped, + MAX(th.action_date) as last_seen, + u.date_registered, + u.last_validation_date + FROM + tasks t + JOIN + task_history th ON t.project_id = th.project_id AND t.id = th.task_id + JOIN + users u ON t.mapped_by = u.id + WHERE + t.project_id = :project_id + AND t.task_status = 2 + AND th.action_text = 'MAPPED' + GROUP BY + u.username, + u.mapping_level, + u.date_registered, + u.last_validation_date + """ + + results = await db.fetch_all(query=query, values={"project_id": project_id}) mapped_tasks_dto = MappedTasks() + for row in results: - user_mapped = MappedTasksByUser() - user_mapped.username = row[0] - user_mapped.mapping_level = MappingLevel(row[1]).name - user_mapped.mapped_task_count = row[2] - user_mapped.tasks_mapped = row[3] - user_mapped.last_seen = row[4] - user_mapped.date_registered = row[5] - user_mapped.last_validation_date = row[6] + tasks_mapped_str = row["tasks_mapped"] + tasks_mapped = json.loads(tasks_mapped_str) if tasks_mapped_str else [] + + mapping_level_value = row["mapping_level"] + mapping_level_name = ( + MappingLevel(mapping_level_value).name + if mapping_level_value is not None + else None + ) + + user_mapped = MappedTasksByUser( + username=row["username"], + mapped_task_count=row["mapped_task_count"], + tasks_mapped=tasks_mapped, + last_seen=row["last_seen"], + mapping_level=mapping_level_name, + date_registered=row["date_registered"], + last_validation_date=row["last_validation_date"], + ) mapped_tasks_dto.mapped_tasks.append(user_mapped) return mapped_tasks_dto @staticmethod - def get_max_task_id_for_project(project_id: int): - """Gets the nights task id currently in use on a project""" - result = ( - db.session.query(func.max(Task.id)) - .filter(Task.project_id == project_id) - .group_by(Task.project_id) - ) - if result.count() == 0: + async def get_max_task_id_for_project(project_id: int, db: Database): + """ + Gets the highest task id currently in use on a project using raw SQL with async db. + """ + query = """ + SELECT MAX(id) + FROM tasks + WHERE project_id = :project_id + GROUP BY project_id + """ + + result = await db.fetch_val(query, values={"project_id": project_id}) + if not result: raise NotFound(sub_code="TASKS_NOT_FOUND", project_id=project_id) - for row in result: - return row[0] + return result + + @staticmethod + async def as_dto( + task_id: int, project_id: int, db: Database, last_updated: str + ) -> TaskDTO: + """Fetch basic TaskDTO details without history or instructions""" + query = """ + SELECT + t.id AS task_id, + t.project_id, + t.task_status, + u.username AS lock_holder + FROM + tasks t + LEFT JOIN + users u ON t.locked_by = u.id + WHERE + t.id = :task_id AND t.project_id = :project_id; + """ + + task = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} + ) + auto_unlock_seconds = await Task.auto_unlock_delta() + task_dto = TaskDTO( + task_id=task["task_id"], + project_id=task["project_id"], + task_status=TaskStatus(task["task_status"]).name, + lock_holder=task["lock_holder"], + last_updated=last_updated, + auto_unlock_seconds=auto_unlock_seconds.total_seconds(), + comments_number=None, # Placeholder, can be populated as needed + ) + return task_dto - def as_dto( + async def task_as_dto( self, task_history: List[TaskHistoryDTO] = [], last_updated: datetime.datetime = None, comments: int = None, + db: Database = None, ): + from backend.services.users.user_service import UserService + """Just converts to a TaskDTO""" task_dto = TaskDTO() task_dto.task_id = self.id task_dto.project_id = self.project_id task_dto.task_status = TaskStatus(self.task_status).name - task_dto.lock_holder = self.lock_holder.username if self.lock_holder else None + user = ( + await UserService.get_user_by_id(self.locked_by, db) + if self.locked_by + else None + ) + task_dto.lock_holder = user.username if user else None task_dto.task_history = task_history task_dto.last_updated = last_updated if last_updated else None - task_dto.auto_unlock_seconds = Task.auto_unlock_delta().total_seconds() + unlock_delta = await Task.auto_unlock_delta() + task_dto.auto_unlock_seconds = ( + unlock_delta.total_seconds() if unlock_delta else None + ) task_dto.comments_number = comments if isinstance(comments, int) else None return task_dto - def as_dto_with_instructions(self, preferred_locale: str = "en") -> TaskDTO: - """Get dto with any task instructions""" + @staticmethod + async def get_task_history( + task_id: int, project_id: int, db: Database + ) -> List[TaskHistoryDTO]: + """Get the task history""" + query = """ + SELECT id, action, action_text, action_date, user_id + FROM task_history + WHERE task_id = :task_id AND project_id = :project_id + ORDER BY action_date DESC + """ + task_history_records = await db.fetch_all( + query, values={"task_id": task_id, "project_id": project_id} + ) + task_history = [] - for action in self.task_history: + for record in task_history_records: history = TaskHistoryDTO() - history.history_id = action.id - history.action = action.action - history.action_text = action.action_text - history.action_date = action.action_date + history.history_id = record.id + history.action = record.action + history.action_text = record.action_text + history.action_date = record.action_date history.action_by = ( - action.actioned_by.username if action.actioned_by else None - ) + record.user_id + ) # Simplified to user_id, username lookup can be done separately history.picture_url = ( - action.actioned_by.picture_url if action.actioned_by else None + None # Add a separate query to fetch user picture if needed ) - if action.task_mapping_issues: - history.issues = [ - issue.as_dto() for issue in action.task_mapping_issues - ] - task_history.append(history) - last_updated = None - if len(task_history) > 0: - last_updated = task_history[0].action_date + return task_history + + @staticmethod + async def as_dto_with_instructions( + task_id: int, project_id: int, db: Database, preferred_locale: str = "en" + ) -> TaskDTO: + """Get DTO with any task instructions""" + + # Query to get task history and associated data + query = """ + SELECT + th.id AS history_id, + th.action, + th.action_text, + th.action_date, + u.username AS action_by, + u.picture_url, + tmi.issue, + tmi.count, + mic.id AS issue_category_id, + p.default_locale + FROM + task_history th + LEFT JOIN + users u ON th.user_id = u.id + LEFT JOIN + task_mapping_issues tmi ON th.id = tmi.task_history_id + LEFT JOIN + mapping_issue_categories mic ON tmi.mapping_issue_category_id = mic.id + LEFT JOIN + projects p ON th.project_id = p.id + WHERE + th.task_id = :task_id AND th.project_id = :project_id + ORDER BY + th.action_date DESC; + """ + rows = await db.fetch_all( + query=query, values={"task_id": task_id, "project_id": project_id} + ) + task_history = [] + + for row in rows: + history = TaskHistoryDTO( + history_id=row["history_id"], + action=row["action"], + action_text=row["action_text"], + action_date=row["action_date"], + action_by=row["action_by"], + picture_url=row["picture_url"], + ) - task_dto = self.as_dto(task_history, last_updated=last_updated) + if row["issue"]: + issues = [] + issue_dto = TaskMappingIssueDTO( + category_id=row["issue_category_id"], + count=row["count"], + name=row["issue"], + ) + issues.append(issue_dto) + history.issues = issues - per_task_instructions = self.get_per_task_instructions(preferred_locale) + task_history.append(history) - # If we don't have instructions in preferred locale try again for default locale - task_dto.per_task_instructions = ( - per_task_instructions - if per_task_instructions - else self.get_per_task_instructions(self.projects.default_locale) + last_updated = ( + task_history[0].action_date.replace(tzinfo=timezone.utc).isoformat() + if task_history + else None ) + task_dto = await Task.as_dto(task_id, project_id, db, last_updated) + per_task_instructions = await Task.get_per_task_instructions( + task_id, project_id, preferred_locale, db + ) + if not per_task_instructions: + query_locale = """ + SELECT + p.default_locale + FROM + projects p + WHERE + p.id = :project_id + """ + default_locale_row = await db.fetch_one( + query=query_locale, values={"project_id": project_id} + ) + default_locale = ( + default_locale_row["default_locale"] if default_locale_row else None + ) - annotations = self.get_per_task_annotations() - task_dto.task_annotations = annotations if annotations else [] + per_task_instructions = ( + await Task.get_per_task_instructions( + task_id, project_id, default_locale, db + ) + if default_locale + else None + ) + task_dto.per_task_instructions = per_task_instructions + task_dto.task_annotations = await Task.get_task_annotations(task_id, db) + task_dto.task_history = task_history return task_dto def get_per_task_annotations(self): result = [ta.get_dto() for ta in self.task_annotations] return result - def get_per_task_instructions(self, search_locale: str) -> str: + @staticmethod + async def get_per_task_instructions( + task_id: int, project_id: int, search_locale: str, db: Database + ) -> str: """Gets any per task instructions attached to the project""" - project_info = self.projects.project_info.all() + query = """ + SELECT + pi.per_task_instructions + FROM + project_info pi + WHERE + pi.project_id = :project_id AND pi.locale = :search_locale; + """ - for info in project_info: - if info.locale == search_locale: - return self.format_per_task_instructions(info.per_task_instructions) + result = await db.fetch_one( + query=query, + values={"project_id": project_id, "search_locale": search_locale}, + ) + return ( + await Task.format_per_task_instructions( + result["per_task_instructions"], task_id, project_id, db + ) + if result + else "" + ) - def format_per_task_instructions(self, instructions) -> str: + @staticmethod + async def format_per_task_instructions( + instructions: str, task_id: int, project_id: int, db: Database + ) -> str: """Format instructions by looking for X, Y, Z tokens and replacing them with the task values""" if not instructions: - return "" # No instructions so return empty string + return "" # No instructions, return empty string + # Query to get the necessary task details (x, y, zoom, etc.) + query = """ + SELECT + t.x, + t.y, + t.zoom, + t.extra_properties + FROM + tasks t + WHERE + t.id = :task_id AND t.project_id = :project_id; + """ + task = await db.fetch_one( + query=query, values={"task_id": task_id, "project_id": project_id} + ) properties = {} - if self.x: - properties["x"] = str(self.x) - if self.y: - properties["y"] = str(self.y) - if self.zoom: - properties["z"] = str(self.zoom) - if self.extra_properties: - properties.update(json.loads(self.extra_properties)) + if task["x"]: + properties["x"] = str(task["x"]) + if task["y"]: + properties["y"] = str(task["y"]) + if task["zoom"]: + properties["z"] = str(task["zoom"]) + if task["extra_properties"]: + properties.update(json.loads(task["extra_properties"])) try: instructions = instructions.format(**properties) except (KeyError, ValueError, IndexError): - # KeyError is raised if a format string contains a key that is not in the dictionary, e.g. {foo} - # ValueError is raised if a format string contains a single { or } - # IndexError is raised if a format string contains empty braces, e.g. {} + # Handle formatting errors pass return instructions - def copy_task_history(self) -> list: - copies = [] - for entry in self.task_history: - db.session.expunge(entry) - make_transient(entry) - entry.id = None - entry.task_id = None - db.session.add(entry) - copies.append(entry) + @staticmethod + async def get_task_annotations( + task_id: int, db: Database + ) -> List[TaskAnnotationDTO]: + """Fetch annotations related to the task""" + query = """ + SELECT + ta.task_id, + ta.annotation_type, + ta.annotation_source, + ta.annotation_markdown, + ta.properties + FROM + task_annotations ta + WHERE + ta.task_id = :task_id; + """ + rows = await db.fetch_all(query=query, values={"task_id": task_id}) + + # Map the query results to TaskAnnotationDTO + return [ + TaskAnnotationDTO( + task_id=row["task_id"], + annotation_type=row["annotation_type"], + annotation_source=row["annotation_source"], + annotation_markdown=row["annotation_markdown"], + properties=row["properties"], + ) + for row in rows + ] + + @staticmethod + async def copy_task_history( + original_task_id: int, new_task_id: int, project_id: int, db: Database + ) -> None: + """ + Copy all task history records from the original task to a new task. + + :param original_task_id: ID of the task whose history is to be copied. + :param new_task_id: ID of the new task to which the history will be copied. + :param project_id: ID of the project associated with the task history. + :param db: Database connection instance. + """ + # Insert the task history with the new_task_id and provided project_id + insert_query = """ + INSERT INTO task_history (project_id, task_id, action, action_text, action_date, user_id) + SELECT :project_id, :new_task_id, action, action_text, action_date, user_id + FROM task_history + WHERE task_id = :original_task_id AND project_id = :project_id + """ + + await db.execute( + insert_query, + values={ + "project_id": project_id, + "new_task_id": new_task_id, + "original_task_id": original_task_id, + }, + ) - return copies + async def get_locked_tasks_for_user( + user_id: int, db: Database + ) -> LockedTasksForUser: + """Gets tasks on projects locked by the specified user id""" - def get_locked_tasks_for_user(user_id: int): - """Gets tasks on project owned by specified user id""" - tasks = Task.query.filter_by(locked_by=user_id) + query = """ + SELECT id, project_id, task_status + FROM tasks + WHERE locked_by = :user_id + """ + + rows = await db.fetch_all(query=query, values={"user_id": user_id}) tasks_dto = LockedTasksForUser() - for task in tasks: - tasks_dto.locked_tasks.append(task.id) - tasks_dto.project = task.project_id - tasks_dto.task_status = TaskStatus(task.task_status).name + + if rows: + tasks_dto.locked_tasks = [row["id"] for row in rows] + tasks_dto.project = rows[0][ + "project_id" + ] # Assuming all tasks belong to the same project + tasks_dto.task_status = TaskStatus(rows[0]["task_status"]).name return tasks_dto - def get_locked_tasks_details_for_user(user_id: int): + async def get_locked_tasks_details_for_user(user_id: int, db: Database) -> list: """Gets tasks on project owned by specified user id""" - tasks = Task.query.filter_by(locked_by=user_id) + query = select(Task).filter_by(locked_by=user_id) + tasks = await db.fetch_all(query) locked_tasks = [task for task in tasks] return locked_tasks diff --git a/backend/models/postgis/task_annotation.py b/backend/models/postgis/task_annotation.py index 155a24da96..ba09c628f9 100644 --- a/backend/models/postgis/task_annotation.py +++ b/backend/models/postgis/task_annotation.py @@ -1,30 +1,42 @@ -from backend.models.postgis.utils import timestamp -from backend import db -from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO +from databases import Database +from sqlalchemy import ( + JSON, + Column, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + String, +) + +from backend.db import Base from backend.models.dtos.project_dto import ProjectTaskAnnotationsDTO +from backend.models.dtos.task_annotation_dto import TaskAnnotationDTO +from backend.models.postgis.utils import timestamp -class TaskAnnotation(db.Model): +class TaskAnnotation(Base): """Describes Task annotaions like derived ML attributes""" __tablename__ = "task_annotations" - id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), index=True) - task_id = db.Column(db.Integer, nullable=False) - annotation_type = db.Column(db.String, nullable=False) - annotation_source = db.Column(db.String) - annotation_markdown = db.Column(db.String) - updated_timestamp = db.Column(db.DateTime, nullable=False, default=timestamp) - properties = db.Column(db.JSON, nullable=False) + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("projects.id"), index=True) + task_id = Column(Integer, nullable=False) + annotation_type = Column(String, nullable=False) + annotation_source = Column(String) + annotation_markdown = Column(String) + updated_timestamp = Column(DateTime, nullable=False, default=timestamp) + properties = Column(JSON, nullable=False) __table_args__ = ( - db.ForeignKeyConstraint( + ForeignKeyConstraint( [task_id, project_id], ["tasks.id", "tasks.project_id"], name="fk_task_annotations", ), - db.Index("idx_task_annotations_composite", "task_id", "project_id"), + Index("idx_task_annotations_composite", "task_id", "project_id"), {}, ) @@ -44,26 +56,21 @@ def __init__( self.annotation_markdown = annotation_markdown self.properties = properties - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def update(self): - """Updates the DB with the current state of the Task Annotations""" - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - @staticmethod - def get_task_annotation(task_id, project_id, annotation_type): - """Get annotations for a task with supplied type""" - return TaskAnnotation.query.filter_by( - project_id=project_id, task_id=task_id, annotation_type=annotation_type - ).one_or_none() + async def get_task_annotation(task_id, project_id, annotation_type, db: Database): + """Get annotations for a task with the supplied type.""" + query = """ + SELECT * FROM task_annotations + WHERE task_id = :task_id AND project_id = :project_id AND annotation_type = :annotation_type + """ + return await db.fetch_one( + query, + values={ + "task_id": task_id, + "project_id": project_id, + "annotation_type": annotation_type, + }, + ) def get_dto(self): task_annotation_dto = TaskAnnotationDTO() @@ -75,44 +82,73 @@ def get_dto(self): return task_annotation_dto @staticmethod - def get_task_annotations_by_project_id_type(project_id, annotation_type): - """Get annotatiols for a project with the supplied type""" - project_task_annotations = TaskAnnotation.query.filter_by( - project_id=project_id, annotation_type=annotation_type - ).all() - - project_task_annotations_dto = ProjectTaskAnnotationsDTO() - project_task_annotations_dto.project_id = project_id - if project_task_annotations: - project_task_annotations_dto = ProjectTaskAnnotationsDTO() - project_task_annotations_dto.project_id = project_id - for row in project_task_annotations: - task_annotation_dto = TaskAnnotationDTO() - task_annotation_dto.task_id = row.task_id - task_annotation_dto.properties = row.properties - task_annotation_dto.annotation_type = row.annotation_type - task_annotation_dto.annotation_source = row.annotation_source - task_annotation_dto.annotation_markdown = row.annotation_markdown - project_task_annotations_dto.tasks.append(task_annotation_dto) + async def get_task_annotations_by_project_id_type( + project_id: int, annotation_type: str, db: Database + ) -> ProjectTaskAnnotationsDTO: + """Get annotations for a project with the supplied type""" + query = """ + SELECT + task_id, + properties, + annotation_type, + annotation_source, + annotation_markdown + FROM + task_annotations + WHERE + project_id = :project_id + AND annotation_type = :annotation_type + """ + + results = await db.fetch_all( + query=query, + values={"project_id": project_id, "annotation_type": annotation_type}, + ) + + project_task_annotations_dto = ProjectTaskAnnotationsDTO(project_id=project_id) + + for row in results: + task_annotation_dto = TaskAnnotationDTO( + task_id=row["task_id"], + properties=row["properties"], + annotation_type=row["annotation_type"], + annotation_source=row["annotation_source"], + annotation_markdown=row["annotation_markdown"], + ) + project_task_annotations_dto.tasks.append(task_annotation_dto) return project_task_annotations_dto @staticmethod - def get_task_annotations_by_project_id(project_id): - """Get annotatiols for a project with the supplied type""" - project_task_annotations = TaskAnnotation.query.filter_by( - project_id=project_id - ).all() - - project_task_annotations_dto = ProjectTaskAnnotationsDTO() - project_task_annotations_dto.project_id = project_id - if project_task_annotations: - for row in project_task_annotations: - task_annotation_dto = TaskAnnotationDTO() - task_annotation_dto.task_id = row.task_id - task_annotation_dto.properties = row.properties - task_annotation_dto.annotation_type = row.annotation_type - task_annotation_dto.annotation_source = row.annotation_source - project_task_annotations_dto.tasks.append(task_annotation_dto) + async def get_task_annotations_by_project_id( + project_id: int, db: Database + ) -> ProjectTaskAnnotationsDTO: + """Get all annotations for a project""" + query = """ + SELECT + task_id, + properties, + annotation_type, + annotation_source, + annotation_markdown + FROM + task_annotations + WHERE + project_id = :project_id + """ + + results = await db.fetch_all(query=query, values={"project_id": project_id}) + + project_task_annotations_dto = ProjectTaskAnnotationsDTO(project_id=project_id) + + for row in results: + task_annotation_dto = TaskAnnotationDTO( + task_id=row["task_id"], + properties=row["properties"], + annotation_type=row["annotation_type"], + annotation_source=row["annotation_source"], + annotation_markdown=row.get("annotation_markdown"), + ) + project_task_annotations_dto.tasks.append(task_annotation_dto) return project_task_annotations_dto diff --git a/backend/models/postgis/team.py b/backend/models/postgis/team.py index 8901eb6c9e..58a22bb656 100644 --- a/backend/models/postgis/team.py +++ b/backend/models/postgis/team.py @@ -1,95 +1,159 @@ -from backend import db +from databases import Database +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + insert, + select, +) +from sqlalchemy.orm import backref, relationship + +from backend.db import Base from backend.exceptions import NotFound +from backend.models.dtos.organisation_dto import OrganisationTeamsDTO from backend.models.dtos.team_dto import ( - TeamDTO, NewTeamDTO, + TeamDTO, TeamMembersDTO, TeamProjectDTO, ) -from backend.models.dtos.organisation_dto import OrganisationTeamsDTO from backend.models.postgis.organisation import Organisation from backend.models.postgis.statuses import ( TeamJoinMethod, - TeamVisibility, TeamMemberFunctions, TeamRoles, + TeamVisibility, ) from backend.models.postgis.user import User from backend.models.postgis.utils import timestamp -class TeamMembers(db.Model): +class TeamMembers(Base): __tablename__ = "team_members" - team_id = db.Column( - db.Integer, db.ForeignKey("teams.id", name="fk_teams"), primary_key=True - ) - user_id = db.Column( - db.BigInteger, db.ForeignKey("users.id", name="fk_users"), primary_key=True + team_id = Column(Integer, ForeignKey("teams.id", name="fk_teams"), primary_key=True) + user_id = Column( + BigInteger, ForeignKey("users.id", name="fk_users"), primary_key=True ) - function = db.Column(db.Integer, nullable=False) # either 'editor' or 'manager' - active = db.Column(db.Boolean, default=False) - join_request_notifications = db.Column( - db.Boolean, nullable=False, default=False + function = Column(Integer, nullable=False) # either 'editor' or 'manager' + active = Column(Boolean, default=False) + join_request_notifications = Column( + Boolean, nullable=False, default=False ) # Managers can turn notifications on/off for team join requests - member = db.relationship( - User, backref=db.backref("teams", cascade="all, delete-orphan") + member = relationship( + User, backref=backref("teams", cascade="all, delete-orphan", lazy="joined") ) - team = db.relationship( - "Team", backref=db.backref("members", cascade="all, delete-orphan") + team = relationship( + "Team", backref=backref("members", cascade="all, delete-orphan", lazy="joined") ) - joined_date = db.Column(db.DateTime, default=timestamp) + joined_date = Column(DateTime, default=timestamp) - def create(self): + async def create(self, db: Database): """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + team_member = await db.execute( + insert(TeamMembers.__table__).values( + team_id=self.team_id, + user_id=self.user_id, + function=self.function, + active=self.active, + join_request_notifications=False, + joined_date=timestamp(), + ) + ) + return team_member - def update(self): + async def update(self, db: Database): """Updates the current model in the DB""" - db.session.commit() + await db.execute( + TeamMembers.__table__.update() + .where(TeamMembers.team_id == self.team_id) + .where(TeamMembers.user_id == self.user_id) + .values( + function=self.function, + active=self.active, + join_request_notifications=self.join_request_notifications, + ) + ) @staticmethod - def get(team_id: int, user_id: int): - """Returns a team member by team_id and user_id""" - return TeamMembers.query.filter_by(team_id=team_id, user_id=user_id).first() + async def get(team_id: int, user_id: int, db: Database): + """ + Returns a team member by team_id and user_id + :param team_id: ID of the team + :param user_id: ID of the user + :param db: async database connection + :return: Team member if found, otherwise None + """ + query = """ + SELECT * FROM team_members + WHERE team_id = :team_id AND user_id = :user_id + """ + member = await db.fetch_one( + query, values={"team_id": team_id, "user_id": user_id} + ) + + return member # Returns the team member if found, otherwise None -class Team(db.Model): +class Team(Base): """Describes a team""" __tablename__ = "teams" # Columns - id = db.Column(db.Integer, primary_key=True) - organisation_id = db.Column( - db.Integer, - db.ForeignKey("organisations.id", name="fk_organisations"), + id = Column(Integer, primary_key=True) + organisation_id = Column( + Integer, + ForeignKey("organisations.id", name="fk_organisations"), nullable=False, ) - name = db.Column(db.String(512), nullable=False) - logo = db.Column(db.String) # URL of a logo - description = db.Column(db.String) - join_method = db.Column( - db.Integer, default=TeamJoinMethod.ANY.value, nullable=False - ) - visibility = db.Column( - db.Integer, default=TeamVisibility.PUBLIC.value, nullable=False - ) + name = Column(String(512), nullable=False) + logo = Column(String) # URL of a logo + description = Column(String) + join_method = Column(Integer, default=TeamJoinMethod.ANY.value, nullable=False) + visibility = Column(Integer, default=TeamVisibility.PUBLIC.value, nullable=False) + + # organisation = relationship(Organisation, backref="teams", lazy="joined") + organisation = relationship(Organisation, backref="teams") + + async def create(self, db: Database): + """Creates and saves the current model to the DB, including members if they exist.""" + + # Create the Team and get the generated team_id + team_id = await db.execute( + insert(Team.__table__) + .values( + organisation_id=self.organisation_id, + name=self.name, + logo=self.logo, + description=self.description, + join_method=self.join_method, + visibility=self.visibility, + ) + .returning(Team.__table__.c.id) + ) + if team_id and self.members: + members_to_insert = [ + { + "team_id": team_id, + "user_id": member.user_id, + "function": member.function, + "active": member.active, + "joined_date": member.joined_date, + "join_request_notifications": member.join_request_notifications, + } + for member in self.members + ] - organisation = db.relationship(Organisation, backref="teams") + await db.execute_many(insert(TeamMembers.__table__), members_to_insert) - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() + return team_id @classmethod - def create_from_dto(cls, new_team_dto: NewTeamDTO): + async def create_from_dto(cls, new_team_dto: NewTeamDTO, db: Database): """Creates a new team from a dto""" new_team = cls() @@ -98,8 +162,8 @@ def create_from_dto(cls, new_team_dto: NewTeamDTO): new_team.join_method = TeamJoinMethod[new_team_dto.join_method].value new_team.visibility = TeamVisibility[new_team_dto.visibility].value - org = Organisation.get(new_team_dto.organisation_id) - new_team.organisation = org + org = await Organisation.get(new_team_dto.organisation_id, db) + new_team.organisation_id = org # Create team member with creator as a manager new_member = TeamMembers() @@ -108,20 +172,22 @@ def create_from_dto(cls, new_team_dto: NewTeamDTO): new_member.function = TeamMemberFunctions.MANAGER.value new_member.active = True new_member.joined_date = timestamp() + new_member.join_request_notifications = False - new_team.members.append(new_member) - - new_team.create() - return new_team + team = await Team.create(new_team, db) + return team - def update(self, team_dto: TeamDTO): + @staticmethod + async def update(team, team_dto: TeamDTO, db: Database): """Updates Team from DTO""" if team_dto.organisation: - self.organisation = Organisation().get_organisation_by_name( - team_dto.organisation + team.organisation = Organisation.get_organisation_by_name( + team_dto.organisation, db ) - for attr, value in team_dto.items(): + # Build the update query for the team attributes + update_fields = {} + for attr, value in team_dto.dict().items(): if attr == "visibility" and value is not None: value = TeamVisibility[team_dto.visibility].value if attr == "join_method" and value is not None: @@ -130,73 +196,76 @@ def update(self, team_dto: TeamDTO): if attr in ("members", "organisation"): continue - try: - is_field_nullable = self.__table__.columns[attr].nullable - if is_field_nullable and value is not None: - setattr(self, attr, value) - elif value is not None: - setattr(self, attr, value) - except KeyError: - continue + if attr in Team.__table__.columns: + update_fields[attr] = value - if team_dto.members != self._get_team_members() and team_dto.members: - for member in self.members: - member_name = User.get_by_id(member.user_id).username - if member_name not in [i["username"] for i in team_dto.members]: - member.delete() - for member in team_dto.members: - user = User.get_by_username(member["username"]) - if user is None: - raise NotFound( - sub_code="USER_NOT_FOUND", username=member["username"] - ) - team_member = TeamMembers.get(self.id, user.id) - if team_member: - team_member.join_request_notifications = member[ - "join_request_notifications" - ] - else: - new_team_member = TeamMembers() - new_team_member.team = self - new_team_member.member = user - new_team_member.function = TeamMemberFunctions[ - member["function"] - ].value - - db.session.commit() - - def delete(self): - """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() - - def can_be_deleted(self) -> bool: - """A Team can be deleted if it doesn't have any projects""" - return len(self.projects) == 0 - - def get(team_id: int): + # Update the team in the database + if update_fields: + update_query = ( + "UPDATE teams SET " + + ", ".join([f"{k} = :{k}" for k in update_fields.keys()]) + + " WHERE id = :id" + ) + await db.execute(update_query, {**update_fields, "id": team.id}) + + # Update team members if they have changed + if team_dto.members: + await Team.update_team_members(team, team_dto, db) + + async def delete(self, db: Database): + """Deletes the current team and its members from the DB""" + + # Delete team members associated with this team + delete_team_members_query = """ + DELETE FROM team_members WHERE team_id = :team_id + """ + await db.execute(delete_team_members_query, values={"team_id": self.id}) + + # Delete the team + delete_team_query = """ + DELETE FROM teams WHERE id = :team_id + """ + await db.execute(delete_team_query, values={"team_id": self.id}) + + @staticmethod + async def can_be_deleted(team_id: int, db: Database) -> bool: + """Check if a Team can be deleted by querying for associated projects""" + query = "SELECT COUNT(*) FROM project_teams WHERE team_id = :team_id" + result = await db.fetch_one(query, {"team_id": team_id}) + return result[0] == 0 + + async def get(team_id: int, db: Database): """ Gets specified team by id :param team_id: team ID in scope :return: Team if found otherwise None """ - return db.session.get(Team, team_id) + query = select(Team).where(Team.id == team_id) + result = await db.fetch_one(query) + return result - def get_team_by_name(team_name: str): + async def get_team_by_name(team_name: str, db: Database): """ Gets specified team by name :param team_name: team name in scope - :return: Team if found otherwise None + :param db: async database connection + :return: Team if found, otherwise None + """ + query = """ + SELECT * FROM teams + WHERE name = :team_name """ - return Team.query.filter_by(name=team_name).one_or_none() + team = await db.fetch_one(query, values={"team_name": team_name}) + + return team # Returns the team if found, otherwise None - def as_dto(self): + async def as_dto(self, db: Database): """Returns a dto for the team""" team_dto = TeamDTO() team_dto.team_id = self.id team_dto.description = self.description team_dto.join_method = TeamJoinMethod(self.join_method).name - team_dto.members = self._get_team_members() + team_dto.members = self._get_team_members(db) team_dto.name = self.name team_dto.organisation = self.organisation.name team_dto.organisation_id = self.organisation.id @@ -204,93 +273,281 @@ def as_dto(self): team_dto.visibility = TeamVisibility(self.visibility).name return team_dto - def as_dto_inside_org(self): + async def as_dto_inside_org(self, db: Database): """Returns a dto for the team""" team_dto = OrganisationTeamsDTO() + team_dto.team_id = self.id team_dto.name = self.name team_dto.description = self.description team_dto.join_method = TeamJoinMethod(self.join_method).name - team_dto.members = self._get_team_members() + team_dto.members = self._get_team_members(db) team_dto.visibility = TeamVisibility(self.visibility).name + return team_dto - def as_dto_team_member(self, member) -> TeamMembersDTO: - """Returns a dto for the team member""" - member_dto = TeamMembersDTO() - user = User.get_by_id(member.user_id) - member_function = TeamMemberFunctions(member.function).name - member_dto.username = user.username - member_dto.function = member_function - member_dto.picture_url = user.picture_url - member_dto.active = member.active - member_dto.join_request_notifications = member.join_request_notifications - member_dto.joined_date = member.joined_date - return member_dto - - def as_dto_team_project(self, project) -> TeamProjectDTO: + async def as_dto_team_member( + user_id: int, team_id: int, db: Database + ) -> TeamMembersDTO: + """Returns a DTO for the team member""" + user_query = """ + SELECT username, picture_url FROM users WHERE id = :user_id + """ + user = await db.fetch_one(query=user_query, values={"user_id": user_id}) + + if not user: + raise NotFound(sub_code="USER_NOT_FOUND", user_id=user_id) + member_query = """ + SELECT function, active, join_request_notifications, joined_date + FROM team_members WHERE user_id = :user_id AND team_id = :team_id + """ + member = await db.fetch_one( + query=member_query, values={"user_id": user_id, "team_id": team_id} + ) + if not member: + raise NotFound(sub_code="MEMBER_NOT_FOUND", user_id=user_id) + + return TeamMembersDTO( + username=user["username"], + function=TeamMemberFunctions(member["function"]).name, + picture_url=user["picture_url"], + active=member["active"], + join_request_notifications=member["join_request_notifications"], + joined_date=member["joined_date"], + ) + + def as_dto_team_project(project) -> TeamProjectDTO: """Returns a dto for the team project""" - project_team_dto = TeamProjectDTO() - project_team_dto.project_name = project.name - project_team_dto.project_id = project.project_id - project_team_dto.role = TeamRoles(project.role).name - return project_team_dto - - def _get_team_members(self): - """Helper to get JSON serialized members""" - members = [] - for mem in self.members: - members.append( - { - "username": mem.member.username, - "pictureUrl": mem.member.picture_url, - "function": TeamMemberFunctions(mem.function).name, - "active": mem.active, - "joinedDate": mem.joined_date.isoformat() - if mem.joined_date - else None, - } - ) + + return TeamProjectDTO( + project_name=project.name, + project_id=project.project_id, + role=TeamRoles(project.role).name, + ) + + async def _get_team_members(self, db: Database): + """Helper to get JSON serialized members using raw SQL queries""" + + query = """ + SELECT u.username, u.picture_url, tm.function, tm.active, tm.joined_date + FROM team_members tm + JOIN users u ON tm.user_id = u.id + WHERE tm.team_id = :team_id + """ + + # Execute the query and fetch all team members + rows = await db.fetch_all(query, {"team_id": self.id}) + + # Convert the fetched rows into a list of dictionaries (JSON serialized format) + members = [ + { + "username": row["username"], + "pictureUrl": row["picture_url"], + "function": TeamMemberFunctions(row["function"]).name, + "active": row["active"], + "joined_date": row["joined_date"], + } + for row in rows + ] return members - def get_team_managers(self, count: int = None): + async def get_all_members(db: Database, team_id: int, count: int = None): """ - Returns users with manager role in the team + Returns all users in the team regardless of their role (manager or member). -------------------------------- - :param count: number of managers to return - :return: list of team managers + :param db: Database session + :param team_id: ID of the team + :param count: Number of members to return + :return: List of team members with specified attributes """ - base_query = TeamMembers.query.filter_by( - team_id=self.id, function=TeamMemberFunctions.MANAGER.value, active=True - ) + + query = f""" + SELECT u.username, + CASE + WHEN tm.function = {TeamMemberFunctions.MANAGER.value} THEN '{TeamMemberFunctions.MANAGER.name}' + WHEN tm.function = {TeamMemberFunctions.MEMBER.value} THEN '{TeamMemberFunctions.MEMBER.name}' + ELSE 'UNKNOWN' + END as function, + tm.active, + tm.join_request_notifications, + u.picture_url + FROM team_members tm + JOIN users u ON tm.user_id = u.id + WHERE tm.team_id = :team_id AND tm.active = true + """ + + values = { + "team_id": team_id, + } + if count: - return base_query.limit(count).all() - else: - return base_query.all() + query += " LIMIT :count" + values["count"] = count + + results = await db.fetch_all(query=query, values=values) + return [TeamMembersDTO(**result) for result in results] - def get_team_members(self, count: int = None): + async def get_team_managers(db: Database, team_id: int, count: int = None): """ - Returns users with member role in the team + Returns users with manager role in the team. -------------------------------- - :param count: number of members to return - :return: list of members in the team + :param db: Database session + :param team_id: ID of the team + :param count: Number of managers to return + :return: List of team managers with specified attributes """ - base_query = TeamMembers.query.filter_by( - team_id=self.id, function=TeamMemberFunctions.MEMBER.value, active=True - ) + query = f""" + SELECT u.username, + CASE + WHEN tm.function = {TeamMemberFunctions.MANAGER.value} THEN '{TeamMemberFunctions.MANAGER.name}' + WHEN tm.function = {TeamMemberFunctions.MEMBER.value} THEN '{TeamMemberFunctions.MEMBER.name}' + ELSE 'UNKNOWN' + END as function, + tm.active, + tm.join_request_notifications, + u.picture_url + FROM team_members tm + JOIN users u ON tm.user_id = u.id + WHERE tm.team_id = :team_id AND tm.function = :function_value AND tm.active = true + """ + + values = { + "team_id": team_id, + "function_value": TeamMemberFunctions.MANAGER.value, + } + + if count: + query += " LIMIT :count" + values["count"] = count + + results = await db.fetch_all(query=query, values=values) + return [TeamMembersDTO(**result) for result in results] + + @staticmethod + async def get_team_members(db: Database, team_id: int, count: int = None): + """ + Returns users with member role in the team. + -------------------------------- + :param db: Database session + :param team_id: ID of the team + :param count: Number of members to return + :return: List of team members with specified attributes + """ + + query = f""" + SELECT u.username, + CASE + WHEN tm.function = {TeamMemberFunctions.MANAGER.value} THEN '{TeamMemberFunctions.MANAGER.name}' + WHEN tm.function = {TeamMemberFunctions.MEMBER.value} THEN '{TeamMemberFunctions.MEMBER.name}' + ELSE 'UNKNOWN' + END as function, + tm.active, + tm.join_request_notifications, + u.picture_url + FROM team_members tm + JOIN users u ON tm.user_id = u.id + WHERE tm.team_id = :team_id AND tm.function = :function_value AND tm.active = true + """ + + values = { + "team_id": team_id, + "function_value": TeamMemberFunctions.MEMBER.value, + } + if count: - return base_query.limit(count).all() - else: - return base_query.all() + query += " LIMIT :count" + values["count"] = count + + results = await db.fetch_all(query=query, values=values) + return [TeamMembersDTO(**result) for result in results] - def get_members_count_by_role(self, role: TeamMemberFunctions): + async def get_members_count_by_role( + db: Database, team_id: int, role: TeamMemberFunctions + ): """ - Returns number of members with specified role in the team + Returns the number of members with the specified role in the team. -------------------------------- - :param role: role to count - :return: number of members with specified role in the team + :param db: Database session + :param team_id: ID of the team + :param role: Role to count + :return: Number of members with the specified role in the team """ - return TeamMembers.query.filter_by( - team_id=self.id, function=role.value, active=True - ).count() + query = """ + SELECT COUNT(*) + FROM team_members + WHERE team_id = :team_id AND function = :function AND active = true + """ + + values = {"team_id": team_id, "function": role.value} + + return await db.fetch_val(query=query, values=values) + + @staticmethod + async def update_team_members(team, team_dto: TeamDTO, db: Database): + # Get existing members from the team + existing_members = await db.fetch_all( + "SELECT user_id FROM team_members WHERE team_id = :team_id", + {"team_id": team.id}, + ) + existing_members_list = list( + set([existing_member.user_id for existing_member in existing_members]) + ) + + new_member_usernames = list( + set([member.username for member in team_dto.members]) + ) + new_members_records = await db.fetch_all( + "SELECT id FROM users WHERE username = ANY(:new_member_usernames)", + {"new_member_usernames": new_member_usernames}, + ) + new_member_list = list( + set([new_member.id for new_member in new_members_records]) + ) + if existing_members_list != new_member_list: + for member in existing_members_list: + if member not in new_member_list: + await db.execute( + "DELETE FROM team_members WHERE team_id = :team_id AND user_id = :user_id", + {"team_id": team.id, "user_id": member}, + ) + + # Add or update members from the new member list + for member in team_dto.members: + user = await db.fetch_one( + "SELECT id FROM users WHERE username = :username", + {"username": member.username}, + ) + if not user: + raise NotFound(sub_code="USER_NOT_FOUND", username=member.username) + # Check if the user is already a member of the team + team_member = await db.fetch_one( + "SELECT * FROM team_members WHERE team_id = :team_id AND user_id = :user_id", + {"team_id": team.id, "user_id": user["id"]}, + ) + if team_member: + await db.execute( + """ + UPDATE team_members + SET join_request_notifications = :join_request_notifications + WHERE team_id = :team_id AND user_id = :user_id + """, + { + "join_request_notifications": member.join_request_notifications, + "team_id": team.id, + "user_id": user["id"], + }, + ) + else: + await db.execute( + """ + INSERT INTO team_members (team_id, user_id, function, join_request_notifications) + VALUES (:team_id, :user_id, :function, :join_request_notifications) + """, + { + "team_id": team.id, + "user_id": user["id"], + "function": TeamMemberFunctions[member["function"]].value, + "join_request_notifications": member.join_request_notifications, + }, + ) diff --git a/backend/models/postgis/user.py b/backend/models/postgis/user.py index e07cb59e42..a64120dac5 100644 --- a/backend/models/postgis/user.py +++ b/backend/models/postgis/user.py @@ -1,112 +1,133 @@ import geojson -from backend import db -from sqlalchemy import desc, func -from geoalchemy2 import functions +from databases import Database +from sqlalchemy import ( + ARRAY, + BigInteger, + Boolean, + Column, + DateTime, + Integer, + String, + delete, + insert, + update, +) +from sqlalchemy.orm import relationship +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.user_dto import ( - UserDTO, - UserMappedProjectsDTO, + ListedUser, MappedProject, - UserFilterDTO, Pagination, - UserSearchQuery, - UserSearchDTO, ProjectParticipantUser, - ListedUser, + UserDTO, + UserFilterDTO, + UserMappedProjectsDTO, + UserSearchDTO, + UserSearchQuery, ) +from backend.models.postgis.interests import Interest, user_interests from backend.models.postgis.licenses import License, user_licenses_table from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.statuses import ( MappingLevel, ProjectStatus, - UserRole, UserGender, + UserRole, ) from backend.models.postgis.utils import timestamp -from backend.models.postgis.interests import Interest, user_interests -class User(db.Model): +class User(Base): """Describes the history associated with a task""" __tablename__ = "users" - id = db.Column(db.BigInteger, primary_key=True, index=True) - username = db.Column(db.String, unique=True) - role = db.Column(db.Integer, default=0, nullable=False) - mapping_level = db.Column(db.Integer, default=1, nullable=False) - tasks_mapped = db.Column(db.Integer, default=0, nullable=False) - tasks_validated = db.Column(db.Integer, default=0, nullable=False) - tasks_invalidated = db.Column(db.Integer, default=0, nullable=False) - projects_mapped = db.Column(db.ARRAY(db.Integer)) - email_address = db.Column(db.String) - is_email_verified = db.Column(db.Boolean, default=False) - is_expert = db.Column(db.Boolean, default=False) - twitter_id = db.Column(db.String) - facebook_id = db.Column(db.String) - linkedin_id = db.Column(db.String) - slack_id = db.Column(db.String) - skype_id = db.Column(db.String) - irc_id = db.Column(db.String) - name = db.Column(db.String) - city = db.Column(db.String) - country = db.Column(db.String) - picture_url = db.Column(db.String) - gender = db.Column(db.Integer) - self_description_gender = db.Column(db.String) - default_editor = db.Column(db.String, default="ID", nullable=False) - mentions_notifications = db.Column(db.Boolean, default=True, nullable=False) - projects_comments_notifications = db.Column( - db.Boolean, default=False, nullable=False - ) - projects_notifications = db.Column(db.Boolean, default=True, nullable=False) - tasks_notifications = db.Column(db.Boolean, default=True, nullable=False) - tasks_comments_notifications = db.Column(db.Boolean, default=False, nullable=False) - teams_announcement_notifications = db.Column( - db.Boolean, default=True, nullable=False - ) - date_registered = db.Column(db.DateTime, default=timestamp) + id = Column(BigInteger, primary_key=True, index=True) + username = Column(String, unique=True) + role = Column(Integer, default=0, nullable=False) + mapping_level = Column(Integer, default=1, nullable=False) + tasks_mapped = Column(Integer, default=0, nullable=False) + tasks_validated = Column(Integer, default=0, nullable=False) + tasks_invalidated = Column(Integer, default=0, nullable=False) + projects_mapped = Column(ARRAY(Integer)) + email_address = Column(String) + is_email_verified = Column(Boolean, default=False) + is_expert = Column(Boolean, default=False) + twitter_id = Column(String) + facebook_id = Column(String) + linkedin_id = Column(String) + slack_id = Column(String) + skype_id = Column(String) + irc_id = Column(String) + name = Column(String) + city = Column(String) + country = Column(String) + picture_url = Column(String) + gender = Column(Integer) + self_description_gender = Column(String) + default_editor = Column(String, default="ID", nullable=False) + mentions_notifications = Column(Boolean, default=True, nullable=False) + projects_comments_notifications = Column(Boolean, default=False, nullable=False) + projects_notifications = Column(Boolean, default=True, nullable=False) + tasks_notifications = Column(Boolean, default=True, nullable=False) + tasks_comments_notifications = Column(Boolean, default=False, nullable=False) + teams_announcement_notifications = Column(Boolean, default=True, nullable=False) + date_registered = Column(DateTime, default=timestamp) # Represents the date the user last had one of their tasks validated - last_validation_date = db.Column(db.DateTime, default=timestamp) + last_validation_date = Column(DateTime, default=timestamp) # Relationships - accepted_licenses = db.relationship( + accepted_licenses = relationship( "License", secondary=user_licenses_table, overlaps="users" ) - interests = db.relationship(Interest, secondary=user_interests, backref="users") - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - db.session.commit() + interests = relationship(Interest, secondary=user_interests, backref="users") @staticmethod - def get_by_id(user_id: int): - """Return the user for the specified id, or None if not found""" - return db.session.get(User, user_id) + async def get_by_id(user_id: int, db: Database): + """ + Return the user for the specified id, or None if not found. + :param user_id: ID of the user to retrieve + :param db: Database connection + :return: User object or None + """ + query = "SELECT * FROM users WHERE id = :user_id" + result = await db.fetch_one(query, values={"user_id": user_id}) + if result is None: + return None + return User(**result) @staticmethod - def get_by_username(username: str): + async def get_by_username(username: str, db: Database): """Return the user for the specified username, or None if not found""" - return User.query.filter_by(username=username).one_or_none() + query = """ + SELECT * FROM users + WHERE username = :username + """ + # Execute the query and fetch the result + result = await db.fetch_one(query, values={"username": username}) + return result if result else None - def update_username(self, username: str): + async def update_username(self, username: str, db: Database): """Update the username""" self.username = username - db.session.commit() + await db.execute( + "UPDATE users SET username = :username WHERE id = :user_id", + values={"user_id": self.id, "username": username}, + ) - def update_picture_url(self, picture_url: str): + async def update_picture_url(self, picture_url: str, db: Database): """Update the profile picture""" self.picture_url = picture_url - db.session.commit() + await db.execute( + "UPDATE users SET picture_url = :picture_url WHERE id = :user_id", + values={"user_id": self.id, "picture_url": picture_url}, + ) - def update(self, user_dto: UserDTO): + async def update(self, user_dto: UserDTO, db: Database): """Update the user details""" - for attr, value in user_dto.items(): + for attr, value in user_dto.dict().items(): if attr == "gender" and value is not None: value = UserGender[value].value @@ -121,225 +142,280 @@ def update(self, user_dto: UserDTO): if user_dto.gender != UserGender.SELF_DESCRIBE.name: self.self_description_gender = None - db.session.commit() - def set_email_verified_status(self, is_verified: bool): + # Create a dictionary for updating fields in the database + update_fields = { + attr: getattr(self, attr) + for attr in self.__dict__ + if attr in self.__table__.columns + } + + await db.execute(update(User).where(User.id == self.id).values(**update_fields)) + + async def set_email_verified_status(self, is_verified: bool, db: Database): """Updates email verfied flag on successfully verified emails""" self.is_email_verified = is_verified - db.session.commit() + query = "UPDATE users SET is_email_verified = :is_email_verified WHERE id = :user_id" + await db.execute( + query, values={"is_email_verified": is_verified, "user_id": self.id} + ) - def set_is_expert(self, is_expert: bool): + async def set_is_expert(self, is_expert: bool, db: Database): """Enables or disables expert mode on the user""" self.is_expert = is_expert - db.session.commit() + query = "UPDATE users SET is_expert = :is_expert WHERE id = :user_id" + await db.execute(query, values={"is_expert": is_expert, "user_id": self.id}) @staticmethod - def get_all_users(query: UserSearchQuery) -> UserSearchDTO: + async def get_all_users(query: UserSearchQuery, db) -> UserSearchDTO: """Search and filter all users""" - # Base query that applies to all searches - base = db.session.query( - User.id, User.username, User.mapping_level, User.role, User.picture_url - ) + base_query = """ + SELECT id, username, mapping_level, role, picture_url FROM users + """ + filters = [] + params = {} - # Add filter to query as required if query.mapping_level: mapping_levels = query.mapping_level.split(",") mapping_level_array = [ MappingLevel[mapping_level].value for mapping_level in mapping_levels ] - base = base.filter(User.mapping_level.in_(mapping_level_array)) + filters.append("mapping_level = ANY(:mapping_levels)") + params["mapping_levels"] = tuple(mapping_level_array) + if query.username: - base = base.filter( - User.username.ilike(("%" + query.username + "%")) - ).order_by( - func.strpos(func.lower(User.username), func.lower(query.username)) - ) + filters.append("username ILIKE :username") + params["username"] = f"%{query.username}%" if query.role: roles = query.role.split(",") role_array = [UserRole[role].value for role in roles] - base = base.filter(User.role.in_(role_array)) + filters.append("role = ANY(:roles)") + params["roles"] = tuple(role_array) + + if filters: + base_query += " WHERE " + " AND ".join(filters) + + base_query += " ORDER BY username" + if query.pagination: - results = base.order_by(User.username).paginate( - page=query.page, per_page=query.per_page, error_out=True - ) + base_query += " LIMIT :limit OFFSET :offset" + base_params = params.copy() + base_params["limit"] = query.per_page + base_params["offset"] = (query.page - 1) * query.per_page + + results = await db.fetch_all(base_query, base_params) + else: - per_page = base.count() - results = base.order_by(User.username).paginate(per_page=per_page) + results = await db.fetch_all(base_query, params) + dto = UserSearchDTO() - for result in results.items: + for result in results: listed_user = ListedUser() - listed_user.id = result.id - listed_user.mapping_level = MappingLevel(result.mapping_level).name - listed_user.username = result.username - listed_user.picture_url = result.picture_url - listed_user.role = UserRole(result.role).name - + listed_user.id = result["id"] + listed_user.mapping_level = MappingLevel(result["mapping_level"]).name + listed_user.username = result["username"] + listed_user.picture_url = result["picture_url"] + listed_user.role = UserRole(result["role"]).name dto.users.append(listed_user) + if query.pagination: - dto.pagination = Pagination(results) + count_query = "SELECT COUNT(*) FROM users" + count_query += " WHERE " + " AND ".join(filters) if filters else "" + total_count = await db.fetch_val(count_query, params) + dto.pagination = Pagination.from_total_count( + query.page, query.per_page, total_count + ) + return dto @staticmethod - def get_all_users_not_paginated(): - """Get all users in DB""" - return db.session.query(User.id).all() + async def get_all_users_not_paginated(db: Database): + """Get all users in the database.""" + query = """ + SELECT id FROM users + """ + return await db.fetch_all(query) @staticmethod - def filter_users(user_filter: str, project_id: int, page: int) -> UserFilterDTO: - """Finds users that matches first characters, for auto-complete. + async def filter_users( + username: str, project_id: int, page: int, db: Database + ) -> UserFilterDTO: + """Finds users that match the first characters, for auto-complete. Users who have participated (mapped or validated) in the project, if given, will be returned ahead of those who have not. """ - # Note that the projects_mapped column includes both mapped and validated projects. - query = ( - db.session.query( - User.username, User.projects_mapped.any(project_id).label("participant") - ) - .filter(User.username.ilike(user_filter.lower() + "%")) - .order_by(desc("participant").nullslast(), User.username) - ) + query = """ + SELECT u.username, :project_id = ANY(u.projects_mapped) AS participant + FROM users u + WHERE u.username ILIKE :username || '%' + ORDER BY participant DESC NULLS LAST, u.username + LIMIT 20 OFFSET :offset + """ + + offset = (page - 1) * 20 + values = { + "username": username.lower(), + "project_id": project_id, + "offset": offset, + } - results = query.paginate(page=page, per_page=20, error_out=True) + results = await db.fetch_all(query, values=values) - if results.total == 0: - raise NotFound(sub_code="USER_NOT_FOUND", username=user_filter) + if not results: + raise NotFound(sub_code="USER_NOT_FOUND", username=username) dto = UserFilterDTO() - for result in results.items: - dto.usernames.append(result.username) + for result in results: + dto.usernames.append(result["username"]) if project_id is not None: - participant = ProjectParticipantUser() - participant.username = result.username - participant.project_id = project_id - participant.is_participant = bool(result.participant) + participant = ProjectParticipantUser( + username=result["username"], + project_id=project_id, + is_participant=bool(result["participant"]), + ) dto.users.append(participant) - dto.pagination = Pagination(results) + total_query = """ + SELECT COUNT(*) FROM users u WHERE u.username ILIKE :username || '%' + """ + total = await db.fetch_val(total_query, values={"username": username.lower()}) + dto.pagination = Pagination.from_total_count( + page=page, per_page=20, total=total + ) + return dto @staticmethod - def upsert_mapped_projects(user_id: int, project_id: int, local_session=None): - """Adds projects to mapped_projects if it doesn't exist""" - if local_session: - query = local_session.query(User).filter_by(id=user_id) - else: - query = User.query.filter_by(id=user_id) - result = query.filter( - User.projects_mapped.op("@>")("{}".format("{" + str(project_id) + "}")) - ).count() + async def upsert_mapped_projects(user_id: int, project_id: int, db: Database): + """Add project to mapped projects if it doesn't exist""" + query = """ + SELECT COUNT(*) + FROM users + WHERE id = :user_id + AND projects_mapped @> ARRAY[:project_id]::integer[] + """ + result = await db.fetch_val( + query, values={"user_id": user_id, "project_id": project_id} + ) + if result > 0: - return # User has previously mapped this project so return - - user = query.one_or_none() - # Fix for new mappers. - if user.projects_mapped is None: - user.projects_mapped = [] - user.projects_mapped.append(project_id) - if local_session: - local_session.commit() - else: - db.session.commit() + return # Project already exists in mapped projects + + # Insert the project_id into the user's mapped projects array + query = """ + UPDATE users + SET projects_mapped = array_append(projects_mapped, :project_id) + WHERE id = :user_id + """ + await db.execute(query, values={"user_id": user_id, "project_id": project_id}) @staticmethod - def get_mapped_projects( - user_id: int, preferred_locale: str + async def get_mapped_projects( + user_id: int, preferred_locale: str, db: Database ) -> UserMappedProjectsDTO: """Get all projects a user has mapped on""" - from backend.models.postgis.task import Task - from backend.models.postgis.project import Project + # Subquery for validated tasks + query_validated = """ + SELECT project_id, COUNT(validated_by) AS validated + FROM tasks + WHERE project_id IN ( + SELECT unnest(projects_mapped) FROM users WHERE id = :user_id + ) AND validated_by = :user_id + GROUP BY project_id, validated_by + """ - query = db.session.query(func.unnest(User.projects_mapped)).filter_by( - id=user_id - ) - query_validated = ( - db.session.query( - Task.project_id.label("project_id"), - func.count(Task.validated_by).label("validated"), - ) - .filter(Task.project_id.in_(query)) - .filter_by(validated_by=user_id) - .group_by(Task.project_id, Task.validated_by) - .subquery() - ) + # Subquery for mapped tasks + query_mapped = """ + SELECT project_id, COUNT(mapped_by) AS mapped + FROM tasks + WHERE project_id IN ( + SELECT unnest(projects_mapped) FROM users WHERE id = :user_id + ) AND mapped_by = :user_id + GROUP BY project_id, mapped_by + """ - query_mapped = ( - db.session.query( - Task.project_id.label("project_id"), - func.count(Task.mapped_by).label("mapped"), - ) - .filter(Task.project_id.in_(query)) - .filter_by(mapped_by=user_id) - .group_by(Task.project_id, Task.mapped_by) - .subquery() - ) + # Union of validated and mapped tasks + query_union = f""" + SELECT COALESCE(v.project_id, m.project_id) AS project_id, + COALESCE(v.validated, 0) AS validated, + COALESCE(m.mapped, 0) AS mapped + FROM ({query_validated}) v + FULL OUTER JOIN ({query_mapped}) m + ON v.project_id = m.project_id + """ - query_union = ( - db.session.query( - func.coalesce( - query_validated.c.project_id, query_mapped.c.project_id - ).label("project_id"), - func.coalesce(query_validated.c.validated, 0).label("validated"), - func.coalesce(query_mapped.c.mapped, 0).label("mapped"), - ) - .join( - query_mapped, - query_validated.c.project_id == query_mapped.c.project_id, - full=True, - ) - .subquery() - ) + # Main query to get project details + query_projects = f""" + SELECT p.id, p.status, p.default_locale, u.mapped, u.validated, ST_AsGeoJSON(p.centroid) AS centroid + FROM projects p + JOIN ({query_union}) u ON p.id = u.project_id + ORDER BY p.id DESC + """ - results = ( - db.session.query( - Project.id, - Project.status, - Project.default_locale, - query_union.c.mapped, - query_union.c.validated, - functions.ST_AsGeoJSON(Project.centroid), - ) - .filter(Project.id == query_union.c.project_id) - .order_by(desc(Project.id)) - .all() - ) + results = await db.fetch_all(query_projects, {"user_id": user_id}) mapped_projects_dto = UserMappedProjectsDTO() for row in results: mapped_project = MappedProject() - mapped_project.project_id = row[0] - mapped_project.status = ProjectStatus(row[1]).name - mapped_project.tasks_mapped = row[3] - mapped_project.tasks_validated = row[4] - mapped_project.centroid = geojson.loads(row[5]) - - project_info = ProjectInfo.get_dto_for_locale( - row[0], preferred_locale, row[2] + mapped_project.project_id = row["id"] + mapped_project.status = ProjectStatus(row["status"]).name + mapped_project.tasks_mapped = row["mapped"] + mapped_project.tasks_validated = row["validated"] + mapped_project.centroid = geojson.loads(row["centroid"]) + project_info = await ProjectInfo.get_dto_for_locale( + db, row["id"], preferred_locale, row["default_locale"] ) mapped_project.name = project_info.name - mapped_projects_dto.mapped_projects.append(mapped_project) - return mapped_projects_dto - def set_user_role(self, role: UserRole): + async def set_user_role(self, role: UserRole, db: Database): """Sets the supplied role on the user""" self.role = role.value - db.session.commit() - def set_mapping_level(self, level: MappingLevel): + query = """ + UPDATE users + SET role = :role + WHERE id = :user_id + """ + await db.execute(query, values={"user_id": self.id, "role": role.value}) + + async def set_mapping_level(self, level: MappingLevel, db: Database): """Sets the supplied level on the user""" self.mapping_level = level.value - db.session.commit() - def accept_license_terms(self, license_id: int): + query = """ + UPDATE users + SET mapping_level = :mapping_level + WHERE id = :user_id + """ + await db.execute( + query, values={"user_id": self.id, "mapping_level": level.value} + ) + + async def accept_license_terms(self, user_id, license_id: int, db: Database): """Associate the user in scope with the supplied license""" - image_license = License.get_by_id(license_id) - self.accepted_licenses.append(image_license) - db.session.commit() + _ = await License.get_by_id(license_id, db) + + query_check = """ + SELECT 1 FROM user_licenses WHERE "user" = :user_id AND "license" = :license_id + """ + record = await db.fetch_one( + query_check, values={"user_id": user_id, "license_id": license_id} + ) + + if not record: + query = """ + INSERT INTO user_licenses ("user", "license") + VALUES (:user_id, :license_id) + """ + await db.execute( + query, values={"user_id": user_id, "license_id": license_id} + ) def has_user_accepted_licence(self, license_id: int): """Test to see if the user has accepted the terms of the specified license""" @@ -350,11 +426,6 @@ def has_user_accepted_licence(self, license_id: int): return False - def delete(self): - """Delete the user in scope from DB""" - db.session.delete(self) - db.session.commit() - def as_dto(self, logged_in_username: str) -> UserDTO: """Create DTO object from user in scope""" user_dto = UserDTO() @@ -366,7 +437,7 @@ def as_dto(self, logged_in_username: str) -> UserDTO: len(self.projects_mapped) if self.projects_mapped else None ) user_dto.is_expert = self.is_expert or False - user_dto.date_registered = self.date_registered + # user_dto.date_registered = self.date_registered user_dto.twitter_id = self.twitter_id user_dto.linkedin_id = self.linkedin_id user_dto.facebook_id = self.facebook_id @@ -398,31 +469,21 @@ def as_dto(self, logged_in_username: str) -> UserDTO: user_dto.self_description_gender = self.self_description_gender return user_dto - def create_or_update_interests(self, interests_ids): - self.interests = [] - objs = [Interest.get_by_id(i) for i in interests_ids] - self.interests.extend(objs) - db.session.commit() - -class UserEmail(db.Model): +class UserEmail(Base): __tablename__ = "users_with_email" - id = db.Column(db.BigInteger, primary_key=True, index=True) - email = db.Column(db.String, nullable=False, unique=True) + id = Column(BigInteger, primary_key=True, index=True) + email = Column(String, nullable=False, unique=True) - def create(self): + async def create(self, db: Database): """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - - def save(self): - db.session.commit() + user = await db.execute(insert(UserEmail.__table__).values(email=self.email)) + return user - def delete(self): + async def delete(self, db: Database): """Deletes the current model from the DB""" - db.session.delete(self) - db.session.commit() + await db.execute(delete(UserEmail.__table__).where(UserEmail.id == self.id)) @staticmethod def get_by_email(email_address: str): diff --git a/backend/models/postgis/utils.py b/backend/models/postgis/utils.py index d2cfeef134..0238626188 100644 --- a/backend/models/postgis/utils.py +++ b/backend/models/postgis/utils.py @@ -1,37 +1,37 @@ import datetime import json import re -from flask import current_app from geoalchemy2 import Geometry from geoalchemy2.functions import GenericFunction +from loguru import logger class NotFound(Exception): """Custom exception to indicate model not found in database""" - pass + def __init__(self, message): + logger.debug(message) class InvalidGeoJson(Exception): """Custom exception to notify caller they have supplied Invalid GeoJson""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class UserLicenseError(Exception): """Custom Exception to notify caller that the user attempting to map has not accepted the license""" - pass + def __init__(self, message): + logger.debug(message) class InvalidData(Exception): """Custom exception to notify caller they have supplied Invalid data to a model""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ST_SetSRID(GenericFunction): diff --git a/backend/pagination.py b/backend/pagination.py new file mode 100644 index 0000000000..33f945a612 --- /dev/null +++ b/backend/pagination.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import typing as t +from math import ceil +from typing import Optional + +import sqlalchemy as sa +import sqlalchemy.orm as sa_orm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.selectable import Select + + +def abort(code): + raise Exception + + +class Pagination: + """Apply an offset and limit to the query based on the current page and number of + items per page. + + Don't create pagination objects manually. They are created by + :meth:`.SQLAlchemy.paginate` and :meth:`.Query.paginate`. + + This is a base class, a subclass must implement :meth:`_query_items` and + :meth:`_query_count`. Those methods will use arguments passed as ``kwargs`` to + perform the queries. + + :param page: The current page, used to calculate the offset. Defaults to the + ``page`` query arg during a request, or 1 otherwise. + :param per_page: The maximum number of items on a page, used to calculate the + offset and limit. Defaults to the ``per_page`` query arg during a request, + or 20 otherwise. + :param max_per_page: The maximum allowed value for ``per_page``, to limit a + user-provided value. Use ``None`` for no limit. Defaults to 100. + :param error_out: Abort with a ``404 Not Found`` error if no items are returned + and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if + either are not ints. + :param count: Calculate the total number of values by issuing an extra count + query. For very complex queries this may be inaccurate or slow, so it can be + disabled and set manually if necessary. + :param kwargs: Information about the query to paginate. Different subclasses will + require different arguments. + + .. versionchanged:: 3.0 + Iterating over a pagination object iterates over its items. + + .. versionchanged:: 3.0 + Creating instances manually is not a public API. + """ + + def __init__( + self, + page: Optional[int] = None, + per_page: Optional[int] = None, + max_per_page: Optional[int] = 100, + error_out: bool = True, + count: bool = True, + **kwargs: t.Any, + ) -> None: + self._query_args = kwargs + page, per_page = self._prepare_page_args( + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + ) + + self.page: int = page + """The current page.""" + + self.per_page: int = per_page + """The maximum number of items on a page.""" + + self.max_per_page: int | None = max_per_page + """The maximum allowed value for ``per_page``.""" + + @staticmethod + async def get(self, count): + items = await self._query_items() + + if not items and self.page != 1 and self.error_out: + abort(404) + + self.items: list[t.Any] = items + """The items on the current page. Iterating over the pagination object is + equivalent to iterating over the items. + """ + + if count: + total = await self._query_count() + else: + total = None + + self.total: int | None = total + """The total number of items across all pages.""" + return self + + @staticmethod + def _prepare_page_args( + *, + page: Optional[int] = None, + per_page: Optional[int] = None, + max_per_page: Optional[int] = None, + error_out: bool = True, + ) -> tuple[int, int]: + if page is None: + page = 1 + + if per_page is None: + per_page = 20 + + if max_per_page is not None: + per_page = min(per_page, max_per_page) + + if page < 1: + if error_out: + abort(404) + else: + page = 1 + + if per_page < 1: + if error_out: + abort(404) + else: + per_page = 20 + + return page, per_page + + @property + def _query_offset(self) -> int: + """The index of the first item to query, passed to ``offset()``. + + :meta private: + + .. versionadded:: 3.0 + """ + return (self.page - 1) * self.per_page + + def _query_items(self) -> list[t.Any]: + """Execute the query to get the items on the current page. + + Uses init arguments stored in :attr:`_query_args`. + + :meta private: + + .. versionadded:: 3.0 + """ + raise NotImplementedError + + def _query_count(self) -> int: + """Execute the query to get the total number of items. + + Uses init arguments stored in :attr:`_query_args`. + + :meta private: + + .. versionadded:: 3.0 + """ + raise NotImplementedError + + @property + def first(self) -> int: + """The number of the first item on the page, starting from 1, or 0 if there are + no items. + + .. versionadded:: 3.0 + """ + if len(self.items) == 0: + return 0 + + return (self.page - 1) * self.per_page + 1 + + @property + def last(self) -> int: + """The number of the last item on the page, starting from 1, inclusive, or 0 if + there are no items. + + .. versionadded:: 3.0 + """ + first = self.first + return max(first, first + len(self.items) - 1) + + @property + def pages(self) -> int: + """The total number of pages.""" + if self.total == 0 or self.total is None: + return 0 + + return ceil(self.total / self.per_page) + + @property + def has_prev(self) -> bool: + """``True`` if this is not the first page.""" + return self.page > 1 + + @property + def prev_num(self) -> int | None: + """The previous page number, or ``None`` if this is the first page.""" + if not self.has_prev: + return None + + return self.page - 1 + + def prev(self, *, error_out: bool = False) -> Pagination: + """Query the :class:`Pagination` object for the previous page. + + :param error_out: Abort with a ``404 Not Found`` error if no items are returned + and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if + either are not ints. + """ + p = type(self)( + page=self.page - 1, + per_page=self.per_page, + error_out=error_out, + count=False, + **self._query_args, + ) + p.total = self.total + return p + + @property + def has_next(self) -> bool: + """``True`` if this is not the last page.""" + return self.page < self.pages + + @property + def next_num(self) -> int | None: + """The next page number, or ``None`` if this is the last page.""" + if not self.has_next: + return None + + return self.page + 1 + + def next(self, *, error_out: bool = False) -> Pagination: + """Query the :class:`Pagination` object for the next page. + + :param error_out: Abort with a ``404 Not Found`` error if no items are returned + and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if + either are not ints. + """ + p = type(self)( + page=self.page + 1, + per_page=self.per_page, + max_per_page=self.max_per_page, + error_out=error_out, + count=False, + **self._query_args, + ) + p.total = self.total + return p + + def iter_pages( + self, + *, + left_edge: int = 2, + left_current: int = 2, + right_current: int = 4, + right_edge: int = 2, + ) -> t.Iterator[int | None]: + """Yield page numbers for a pagination widget. Skipped pages between the edges + and middle are represented by a ``None``. + + For example, if there are 20 pages and the current page is 7, the following + values are yielded. + + .. code-block:: python + + 1, 2, None, 5, 6, 7, 8, 9, 10, 11, None, 19, 20 + + :param left_edge: How many pages to show from the first page. + :param left_current: How many pages to show left of the current page. + :param right_current: How many pages to show right of the current page. + :param right_edge: How many pages to show from the last page. + + .. versionchanged:: 3.0 + Improved efficiency of calculating what to yield. + + .. versionchanged:: 3.0 + ``right_current`` boundary is inclusive. + + .. versionchanged:: 3.0 + All parameters are keyword-only. + """ + pages_end = self.pages + 1 + + if pages_end == 1: + return + + left_end = min(1 + left_edge, pages_end) + yield from range(1, left_end) + + if left_end == pages_end: + return + + mid_start = max(left_end, self.page - left_current) + mid_end = min(self.page + right_current + 1, pages_end) + + if mid_start - left_end > 0: + yield None + + yield from range(mid_start, mid_end) + + if mid_end == pages_end: + return + + right_start = max(mid_end, pages_end - right_edge) + + if right_start - mid_end > 0: + yield None + + yield from range(right_start, pages_end) + + def __iter__(self) -> t.Iterator[t.Any]: + yield from self.items + + +class SelectPagination(Pagination): + """Returned by :meth:`.SQLAlchemy.paginate`. Takes ``select`` and ``session`` + arguments in addition to the :class:`Pagination` arguments. + + .. versionadded:: 3.0 + """ + + def _query_items(self) -> list[t.Any]: + select = self._query_args["select"] + select = select.limit(self.per_page).offset(self._query_offset) + session = self._query_args["session"] + return list(session.execute(select).unique().scalars()) + + def _query_count(self) -> int: + select = self._query_args["select"] + sub = select.options(sa_orm.lazyload("*")).order_by(None).subquery() + session = self._query_args["session"] + out = session.execute(sa.select(sa.func.count()).select_from(sub)).scalar() + return out # type: ignore[no-any-return] + + +class QueryPagination(Pagination): + """Returned by :meth:`.Query.paginate`. Takes a ``query`` argument in addition to + the :class:`Pagination` arguments. + + .. versionadded:: 3.0 + """ + + async def _query_items(self) -> list[t.Any]: + query = self._query_args["query"] + session = self._query_args["session"] + out = await session.execute( + query.limit(self.per_page).offset(self._query_offset) + ) + return out # type: ignore[no-any-return] + + async def _query_count(self) -> int: + # Query.count automatically disables eager loads + session = self._query_args["session"] + out = await session.scalar( + sa.select(sa.func.count()).select_from(self._query_args["query"]) + ) + return out # type: ignore[no-any-return] + + +# @inherit_cache +class CustomQuery(Select): + async def paginate( + self, + *, + session: AsyncSession, + page: Optional[int] = None, + per_page: Optional[int] = None, + max_per_page: Optional[int] = None, + error_out: bool = True, + count: bool = True, + ) -> Pagination: + """Apply an offset and limit to the query based on the current page and number + of items per page, returning a :class:`.Pagination` object. + + :param page: The current page, used to calculate the offset. Defaults to the + ``page`` query arg during a request, or 1 otherwise. + :param per_page: The maximum number of items on a page, used to calculate the + offset and limit. Defaults to the ``per_page`` query arg during a request, + or 20 otherwise. + :param max_per_page: The maximum allowed value for ``per_page``, to limit a + user-provided value. Use ``None`` for no limit. Defaults to 100. + :param error_out: Abort with a ``404 Not Found`` error if no items are returned + and ``page`` is not 1, or if ``page`` or ``per_page`` is less than 1, or if + either are not ints. + :param count: Calculate the total number of values by issuing an extra count + query. For very complex queries this may be inaccurate or slow, so it can be + disabled and set manually if necessary. + + .. versionchanged:: 3.0 + All parameters are keyword-only. + + .. versionchanged:: 3.0 + The ``count`` query is more efficient. + + .. versionchanged:: 3.0 + ``max_per_page`` defaults to 100. + """ + query = QueryPagination( + query=self, + session=session, + page=page, + per_page=per_page, + max_per_page=max_per_page, + error_out=error_out, + count=count, + ) + return await query.get(query, count) diff --git a/backend/routes.py b/backend/routes.py new file mode 100644 index 0000000000..4c924d7f8f --- /dev/null +++ b/backend/routes.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter + +from backend.api.annotations import resources as annotation_resources +from backend.api.campaigns import resources as campaign_resources +from backend.api.comments import resources as comment_resources +from backend.api.countries import resources as country_resources +from backend.api.interests import resources as interest_resources +from backend.api.issues import resources as issue_resources +from backend.api.licenses import actions as license_actions +from backend.api.licenses import resources as license_resources +from backend.api.notifications import actions as notification_actions +from backend.api.notifications import resources as notification_resources +from backend.api.organisations import campaigns as organisation_campaigns +from backend.api.organisations import resources as organisation_resources +from backend.api.partners import resources as partners_resources +from backend.api.partners import statistics as partners_statistics +from backend.api.projects import actions as project_actions +from backend.api.projects import activities as project_activities +from backend.api.projects import campaigns as project_campaigns +from backend.api.projects import contributions as project_contributions +from backend.api.projects import favorites as project_favorites +from backend.api.projects import partnerships as project_partnerships +from backend.api.projects import resources as project_resources +from backend.api.projects import statistics as project_statistics +from backend.api.projects import teams as project_teams +from backend.api.system import applications as system_applications +from backend.api.system import authentication as system_authentication +from backend.api.system import banner as system_banner +from backend.api.system import general as system_general +from backend.api.system import image_upload as system_image_upload +from backend.api.system import statistics as system_statistics +from backend.api.tasks import actions as task_actions +from backend.api.tasks import resources as task_resources +from backend.api.tasks import statistics as task_statistics +from backend.api.teams import actions as teams_actions +from backend.api.teams import resources as teams_resources +from backend.api.users import actions as user_actions +from backend.api.users import openstreetmap as users_openstreetmap +from backend.api.users import resources as user_resources +from backend.api.users import statistics as user_statistics +from backend.api.users import tasks as users_tasks + +v2 = APIRouter(prefix="/api/v2") + + +def add_api_end_points(api): + v2.include_router(project_resources.router) + v2.include_router(project_activities.router) + v2.include_router(project_contributions.router) + v2.include_router(project_statistics.router) + v2.include_router(project_teams.router) + v2.include_router(project_campaigns.router) + v2.include_router(project_actions.router) + v2.include_router(project_favorites.router) + v2.include_router(project_partnerships.router) + + # Comments REST endpoint + v2.include_router(comment_resources.router) + + # Teams REST endpoint + v2.include_router(teams_resources.router) + v2.include_router(teams_actions.router) + + # Countries REST endpoint + v2.include_router(country_resources.router) + + # Campaigns REST endpoint + v2.include_router(campaign_resources.router) + + # Annotations REST endpoint + v2.include_router(annotation_resources.router) + + # Interests REST endpoint + v2.include_router(interest_resources.router) + + # Users REST endpoint + v2.include_router(user_statistics.router) + v2.include_router(user_resources.router) + v2.include_router(users_openstreetmap.router) + v2.include_router(users_tasks.router) + v2.include_router(user_statistics.router) + v2.include_router(user_actions.router) + + # Licenses REST endpoint + v2.include_router(license_resources.router) + v2.include_router(license_actions.router) + + # Organisations REST endpoint + v2.include_router(organisation_resources.router) + v2.include_router(organisation_campaigns.router) + + # Tasks REST endpoint + v2.include_router(task_resources.router) + v2.include_router(task_actions.router) + v2.include_router(task_statistics.router) + + # System REST endpoint + v2.include_router(system_applications.router) + v2.include_router(system_general.router) + v2.include_router(system_banner.router) + v2.include_router(system_statistics.router) + v2.include_router(system_authentication.router) + v2.include_router(system_image_upload.router) + + # Notifications REST endpoint + v2.include_router(notification_actions.router) + v2.include_router(notification_resources.router) + + # Issues REST endpoint + v2.include_router(issue_resources.router) + v2.include_router(partners_resources.router) + v2.include_router(partners_statistics.router) + + api.include_router(v2) diff --git a/backend/services/application_service.py b/backend/services/application_service.py index b29eb2a4df..fa1ed52548 100644 --- a/backend/services/application_service.py +++ b/backend/services/application_service.py @@ -1,3 +1,5 @@ +from databases import Database + from backend.exceptions import NotFound from backend.models.postgis.application import Application from backend.services.users.authentication_service import AuthenticationService @@ -5,14 +7,14 @@ class ApplicationService: @staticmethod - def create_token(user_id: int) -> Application: - application = Application().create(user_id) + async def create_token(user_id: int, db: Database) -> Application: + application = await Application().create(user_id, db) return application.as_dto() @staticmethod - def get_token(token: str): - application = Application.get_token(token) + async def get_token(token: str, db: Database): + application = await Application.get_token(token, db) if application is None: raise NotFound(sub_code="APPLICATION_NOT_FOUND") @@ -20,14 +22,14 @@ def get_token(token: str): return application @staticmethod - def get_all_tokens_for_logged_in_user(user_id: int): - tokens = Application.get_all_for_user(user_id) + async def get_all_tokens_for_logged_in_user(user_id: int, db: Database): + tokens = await Application.get_all_for_user(user_id, db) return tokens @staticmethod - def check_token(token: str): - valid_token = ApplicationService.get_token(token) + async def check_token(token: str, db: Database): + valid_token = await ApplicationService.get_token(token, db) if not valid_token: return False diff --git a/backend/services/campaign_service.py b/backend/services/campaign_service.py index 9f00f6dded..796f19c0f1 100644 --- a/backend/services/campaign_service.py +++ b/backend/services/campaign_service.py @@ -1,211 +1,349 @@ -from backend import db -from flask import current_app +from databases import Database +from fastapi import HTTPException from sqlalchemy.exc import IntegrityError -from psycopg2.errors import UniqueViolation, NotNullViolation from backend.exceptions import NotFound from backend.models.dtos.campaign_dto import ( CampaignDTO, - NewCampaignDTO, - CampaignProjectDTO, CampaignListDTO, + CampaignProjectDTO, + NewCampaignDTO, ) -from backend.models.postgis.campaign import ( - Campaign, - campaign_projects, - campaign_organisations, -) -from backend.models.postgis.organisation import Organisation +from backend.models.postgis.campaign import Campaign from backend.services.organisation_service import OrganisationService from backend.services.project_service import ProjectService class CampaignService: @staticmethod - def get_campaign(campaign_id: int) -> Campaign: - """Gets the specified campaign""" - campaign = db.session.get(Campaign, campaign_id) + async def get_campaign(campaign_id: int, db: Database) -> CampaignDTO: + """Gets the specified campaign by its ID""" + query = """ + SELECT id, name, logo, url, description + FROM campaigns + WHERE id = :campaign_id + """ + row = await db.fetch_one(query=query, values={"campaign_id": campaign_id}) - if campaign is None: + if row is None: raise NotFound(sub_code="CAMPAIGN_NOT_FOUND", campaign_id=campaign_id) - return campaign + return CampaignDTO(**row) @staticmethod - def get_campaign_by_name(campaign_name: str) -> Campaign: - campaign = Campaign.query.filter_by(name=campaign_name).first() + async def get_campaign_by_name(campaign_name: str, db: Database) -> CampaignDTO: + """Gets the specified campaign by its name""" + query = """ + SELECT id, name, logo, url, description + FROM campaigns + WHERE name = :campaign_name + """ + row = await db.fetch_one(query=query, values={"campaign_name": campaign_name}) - if campaign is None: + if row is None: raise NotFound(sub_code="CAMPAIGN_NOT_FOUND", campaign_name=campaign_name) - return campaign + return CampaignDTO(**row) @staticmethod - def delete_campaign(campaign_id: int): - """Delete campaign for a project""" - campaign = db.session.get(Campaign, campaign_id) - campaign.delete() - campaign.save() + async def delete_campaign(campaign_id: int, db: Database): + """Delete a campaign and its related organizations by its ID""" + # Begin a transaction to ensure both deletions are handled together + async with db.transaction(): + query_delete_orgs = """ + DELETE FROM campaign_organisations + WHERE campaign_id = :campaign_id + """ + await db.execute( + query=query_delete_orgs, values={"campaign_id": campaign_id} + ) + + query_delete_campaign = """ + DELETE FROM campaigns + WHERE id = :campaign_id + """ + await db.execute( + query=query_delete_campaign, values={"campaign_id": campaign_id} + ) @staticmethod - def get_campaign_as_dto(campaign_id: int, user_id: int): + async def get_campaign_as_dto(campaign_id: int, db) -> CampaignDTO: """Gets the specified campaign""" - campaign = CampaignService.get_campaign(campaign_id) - - campaign_dto = CampaignDTO() - campaign_dto.id = campaign.id - campaign_dto.url = campaign.url - campaign_dto.name = campaign.name - campaign_dto.logo = campaign.logo - campaign_dto.description = campaign.description - - return campaign_dto + campaign = await CampaignService.get_campaign(campaign_id, db) + return campaign @staticmethod - def get_project_campaigns_as_dto(project_id: int) -> CampaignListDTO: + async def get_project_campaigns_as_dto( + project_id: int, db: Database + ) -> CampaignListDTO: """Gets all the campaigns for a specified project""" # Test if project exists - ProjectService.get_project_by_id(project_id) - query = ( - Campaign.query.join(campaign_projects) - .filter(campaign_projects.c.project_id == project_id) - .all() - ) + await ProjectService.get_project_by_id(project_id, db) - return Campaign.campaign_list_as_dto(query) + query = """ + SELECT c.* + FROM campaigns c + INNER JOIN campaign_projects cp ON c.id = cp.campaign_id + WHERE cp.project_id = :project_id + """ + + campaigns = await db.fetch_all(query=query, values={"project_id": project_id}) + return Campaign.campaign_list_as_dto(campaigns) @staticmethod - def delete_project_campaign(project_id: int, campaign_id: int): - """Delete campaign for a project""" - campaign = CampaignService.get_campaign(campaign_id) - project = ProjectService.get_project_by_id(project_id) - project_campaigns = CampaignService.get_project_campaigns_as_dto(project_id) - if campaign.id not in [i["id"] for i in project_campaigns["campaigns"]]: + async def delete_project_campaign(project_id: int, campaign_id: int, db: Database): + """Delete campaign from a project.""" + # Check if the campaign exists + await CampaignService.get_campaign(campaign_id, db) + + # Check if the project exists + await ProjectService.get_project_by_id(project_id, db) + + """Fetch all campaigns associated with a project.""" + query = """ + SELECT c.id + FROM campaigns c + JOIN campaign_projects pc ON c.id = pc.campaign_id + WHERE pc.project_id = :project_id + """ + project_campaigns = await db.fetch_all( + query=query, values={"project_id": project_id} + ) + + if campaign_id not in [campaign.id for campaign in project_campaigns]: raise NotFound( sub_code="PROJECT_CAMPAIGN_NOT_FOUND", campaign_id=campaign_id, project_id=project_id, ) - project.campaign.remove(campaign) - db.session.commit() - new_campaigns = CampaignService.get_project_campaigns_as_dto(project_id) - return new_campaigns + + # Delete the campaign from the project + delete_query = """ + DELETE FROM campaign_projects + WHERE project_id = :project_id + AND campaign_id = :campaign_id + """ + await db.execute( + delete_query, values={"project_id": project_id, "campaign_id": campaign_id} + ) + # Fetch the updated list of campaigns + updated_campaigns = await CampaignService.get_project_campaigns_as_dto( + project_id, db + ) + return updated_campaigns @staticmethod - def get_all_campaigns() -> CampaignListDTO: + async def get_all_campaigns(db: Database) -> CampaignListDTO: """Returns a list of all campaigns""" - query = Campaign.query.order_by(Campaign.name).distinct() - - return Campaign.campaign_list_as_dto(query) + # Define the raw SQL query + query = """ + SELECT DISTINCT id, name + FROM campaigns + ORDER BY name + """ + rows = await db.fetch_all(query) + return Campaign.campaign_list_as_dto(rows) @staticmethod - def create_campaign(campaign_dto: NewCampaignDTO): - """Creates a new campaign""" - campaign = Campaign.from_dto(campaign_dto) + async def create_campaign(campaign_dto: NewCampaignDTO, db: Database): + """Creates a new campaign asynchronously""" try: - campaign.create() - if campaign_dto.organisations: - for org_id in campaign_dto.organisations: - organisation = OrganisationService.get_organisation_by_id(org_id) - campaign.organisation.append(organisation) - db.session.commit() - except IntegrityError as e: - current_app.logger.info("Integrity error: {}".format(e.args[0])) - if isinstance(e.orig, UniqueViolation): - raise ValueError("NameExists- Campaign name already exists") from e - if isinstance(e.orig, NotNullViolation): - raise ValueError("NullName- Campaign name cannot be null") from e - return campaign + async with db.transaction(): + # Generate the base query and values + query = """ + INSERT INTO campaigns (name, logo, url, description) + VALUES (:name, :logo, :url, :description) + RETURNING id + """ + values = { + "name": campaign_dto.name, + "logo": campaign_dto.logo, + "url": campaign_dto.url, + "description": campaign_dto.description, + } + + campaign_id = await db.execute(query, values) + if campaign_dto.organisations: + for org_id in campaign_dto.organisations: + organisation = await OrganisationService.get_organisation_by_id( + org_id, db + ) + if organisation: + org_query = """ + INSERT INTO campaign_organisations (campaign_id, organisation_id) + VALUES (:campaign_id, :organisation_id) + """ + await db.execute( + org_query, + {"campaign_id": campaign_id, "organisation_id": org_id}, + ) + + return campaign_id + except Exception as e: + raise HTTPException( + status_code=500, detail="Failed to create campaign." + ) from e @staticmethod - def create_campaign_project(dto: CampaignProjectDTO): - """Assign a campaign with a project""" - ProjectService.get_project_by_id(dto.project_id) - CampaignService.get_campaign(dto.campaign_id) - statement = campaign_projects.insert().values( - campaign_id=dto.campaign_id, project_id=dto.project_id + async def create_campaign_project( + dto: CampaignProjectDTO, db: Database + ) -> CampaignListDTO: + """Assign a campaign to a project""" + + # Check if the project exists + await ProjectService.get_project_by_id(dto.project_id, db) + + # Check if the campaign exists + await CampaignService.get_campaign(dto.campaign_id, db) + + insert_query = """ + INSERT INTO campaign_projects (campaign_id, project_id) + VALUES (:campaign_id, :project_id) + """ + + await db.execute( + query=insert_query, + values={"campaign_id": dto.campaign_id, "project_id": dto.project_id}, + ) + new_campaigns = await CampaignService.get_project_campaigns_as_dto( + dto.project_id, db ) - db.session.execute(statement) - db.session.commit() - new_campaigns = CampaignService.get_project_campaigns_as_dto(dto.project_id) return new_campaigns @staticmethod - def create_campaign_organisation(organisation_id: int, campaign_id: int): - """Creates new campaign from DTO""" + async def create_campaign_organisation( + organisation_id: int, campaign_id: int, db: Database + ): + """Creates new campaign organisation from DTO""" # Check if campaign exists - CampaignService.get_campaign(campaign_id) + await CampaignService.get_campaign(campaign_id, db) # Check if organisation exists - OrganisationService.get_organisation_by_id(organisation_id) + await OrganisationService.get_organisation_by_id(organisation_id, db) - statement = campaign_organisations.insert().values( - campaign_id=campaign_id, organisation_id=organisation_id + query = """ + INSERT INTO campaign_organisations (campaign_id, organisation_id) + VALUES (:campaign_id, :organisation_id) + """ + await db.execute( + query=query, + values={"campaign_id": campaign_id, "organisation_id": organisation_id}, ) - db.session.execute(statement) - db.session.commit() - new_campaigns = CampaignService.get_organisation_campaigns_as_dto( - organisation_id - ) - return new_campaigns @staticmethod - def get_organisation_campaigns_as_dto(organisation_id: int) -> CampaignListDTO: - """Gets all the campaigns for a specified project""" + async def get_organisation_campaigns_as_dto( + organisation_id: int, database: Database + ) -> CampaignListDTO: + """Gets all the campaigns for a specified organisation""" + # Check if organisation exists - OrganisationService.get_organisation_by_id(organisation_id) - query = ( - Campaign.query.join(campaign_organisations) - .filter(campaign_organisations.c.organisation_id == organisation_id) - .all() + await OrganisationService.get_organisation_by_id(organisation_id, database) + + query = """ + SELECT c.* + FROM campaigns c + JOIN campaign_organisations co ON c.id = co.campaign_id + WHERE co.organisation_id = :organisation_id + """ + campaigns = await database.fetch_all( + query=query, values={"organisation_id": organisation_id} ) - return Campaign.campaign_list_as_dto(query) + + # Convert the result to a list of campaign DTOs + return Campaign.campaign_list_as_dto(campaigns) @staticmethod - def campaign_organisation_exists(campaign_id: int, org_id: int): - return ( - Campaign.query.join(campaign_organisations) - .filter( - campaign_organisations.c.organisation_id == org_id, - campaign_organisations.c.campaign_id == campaign_id, - ) - .one_or_none() + async def campaign_organisation_exists( + campaign_id: int, org_id: int, database: Database + ) -> bool: + query = """ + SELECT 1 + FROM campaign_organisations + WHERE organisation_id = :org_id + AND campaign_id = :campaign_id + LIMIT 1 + """ + result = await database.fetch_one( + query=query, values={"org_id": org_id, "campaign_id": campaign_id} ) + return result is not None @staticmethod - def delete_organisation_campaign(organisation_id: int, campaign_id: int): - """Delete campaign for a organisation""" - campaign = db.session.get(Campaign, campaign_id) - if not campaign: + async def delete_organisation_campaign( + organisation_id: int, campaign_id: int, db: Database + ): + """Delete campaign for an organisation""" + + # Check if campaign exists + query_campaign = "SELECT 1 FROM campaigns WHERE id = :campaign_id LIMIT 1" + campaign_exists = await db.fetch_one( + query=query_campaign, values={"campaign_id": campaign_id} + ) + if not campaign_exists: raise NotFound(sub_code="CAMPAIGN_NOT_FOUND", campaign_id=campaign_id) - org = db.session.get(Organisation, organisation_id) - if not org: + + # Check if organisation exists + query_org = "SELECT 1 FROM organisations WHERE id = :organisation_id LIMIT 1" + org_exists = await db.fetch_one( + query=query_org, values={"organisation_id": organisation_id} + ) + if not org_exists: raise NotFound( sub_code="ORGANISATION_NOT_FOUND", organisation_id=organisation_id ) - if not CampaignService.campaign_organisation_exists( - campaign_id, organisation_id - ): + + campaign_org_exists = await CampaignService.campaign_organisation_exists( + campaign_id, organisation_id, db + ) + if not campaign_org_exists: raise NotFound( sub_code="ORGANISATION_CAMPAIGN_NOT_FOUND", organisation_id=organisation_id, campaign_id=campaign_id, ) - org.campaign.remove(campaign) - db.session.commit() - new_campaigns = CampaignService.get_organisation_campaigns_as_dto( - organisation_id + + query_delete = """ + DELETE FROM campaign_organisations + WHERE campaign_id = :campaign_id + AND organisation_id = :organisation_id + """ + await db.execute( + query=query_delete, + values={"campaign_id": campaign_id, "organisation_id": organisation_id}, ) - return new_campaigns @staticmethod - def update_campaign(campaign_dto: CampaignDTO, campaign_id: int): - campaign = db.session.get(Campaign, campaign_id) + async def update_campaign( + campaign_dto: CampaignDTO, campaign_id: int, db: Database + ): + campaign_query = "SELECT * FROM campaigns WHERE id = :id" + campaign = await db.fetch_one(query=campaign_query, values={"id": campaign_id}) + if not campaign: raise NotFound(sub_code="CAMPAIGN_NOT_FOUND", campaign_id=campaign_id) try: - campaign.update(campaign_dto) - except IntegrityError as e: - current_app.logger.info("Integrity error: {}".format(e.args[0])) - raise ValueError() + # Convert the DTO to a dictionary, excluding unset fields + campaign_dict = campaign_dto.dict(exclude_unset=True) + # Remove 'organisation' key if it exists + if "organisations" in campaign_dict: + del campaign_dict["organisations"] - return campaign + set_clause = ", ".join(f"{key} = :{key}" for key in campaign_dict.keys()) + update_query = f""" + UPDATE campaigns + SET {set_clause} + WHERE id = :id + RETURNING id + """ + campaign = await db.fetch_one( + query=update_query, values={**campaign_dict, "id": campaign_id} + ) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + return campaign + + except IntegrityError: + raise HTTPException(status_code=409, detail="Campaign name already exists") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/backend/services/grid/grid_service.py b/backend/services/grid/grid_service.py index 7564f92d8b..7a634a42a0 100644 --- a/backend/services/grid/grid_service.py +++ b/backend/services/grid/grid_service.py @@ -1,9 +1,11 @@ -import geojson import json + +import geojson +import shapely.geometry +from loguru import logger from shapely.geometry import MultiPolygon, mapping from shapely.ops import unary_union -import shapely.geometry -from flask import current_app + from backend.models.dtos.grid_dto import GridDTO from backend.models.postgis.utils import InvalidGeoJson @@ -12,8 +14,7 @@ class GridServiceError(Exception): """Custom Exception to notify callers an error occurred when handling projects""" def __init__(self, message): - if current_app: - current_app.logger.error(message) + logger.error(message) class GridService: diff --git a/backend/services/grid/split_service.py b/backend/services/grid/split_service.py index fe57b04015..660a9de0da 100644 --- a/backend/services/grid/split_service.py +++ b/backend/services/grid/split_service.py @@ -1,16 +1,16 @@ import geojson -from shapely.geometry import Polygon, MultiPolygon, LineString, shape as shapely_shape -from shapely.ops import split -from backend import db -from flask import current_app +from databases import Database + from geoalchemy2 import shape +from geoalchemy2.elements import WKBElement +from loguru import logger +from shapely.geometry import LineString, MultiPolygon, Polygon +from shapely.geometry import shape as shapely_shape +from shapely.ops import split -from backend.exceptions import NotFound from backend.models.dtos.grid_dto import SplitTaskDTO from backend.models.dtos.mapping_dto import TaskDTOs -from backend.models.postgis.utils import ST_Transform, ST_Area, ST_GeogFromWKB -from backend.models.postgis.task import Task, TaskStatus, TaskAction -from backend.models.postgis.project import Project +from backend.models.postgis.task import Task, TaskAction, TaskStatus from backend.models.postgis.utils import InvalidGeoJson @@ -18,22 +18,17 @@ class SplitServiceError(Exception): """Custom Exception to notify callers an error occurred when handling splitting tasks""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class SplitService: @staticmethod - def _create_split_tasks(x, y, zoom, task) -> list: + async def _create_split_tasks(x, y, zoom, task, db) -> list: """ - function for splitting a task square geometry into 4 smaller squares - :param geom_to_split: {geojson.Feature} the geojson feature to b split - :return: list of {geojson.Feature} + Splitting a task square geometry into 4 smaller squares. """ - # If the task's geometry doesn't correspond to an OSM tile identified by an - # x, y, zoom then we need to take a different approach to splitting if x is None or y is None or zoom is None or not task.is_square: - return SplitService._create_split_tasks_from_geometry(task) + return await SplitService._create_split_tasks_from_geometry(task, db) try: split_geoms = [] @@ -42,7 +37,9 @@ def _create_split_tasks(x, y, zoom, task) -> list: new_x = x * 2 + i new_y = y * 2 + j new_zoom = zoom + 1 - new_square = SplitService._create_square(new_x, new_y, new_zoom) + new_square = await SplitService._create_square( + new_x, new_y, new_zoom, db + ) feature = geojson.Feature() feature.geometry = new_square feature.properties = { @@ -56,62 +53,71 @@ def _create_split_tasks(x, y, zoom, task) -> list: split_geoms.append(feature) return split_geoms + except Exception as e: raise SplitServiceError(f"unhandled error splitting tile: {str(e)}") @staticmethod - def _create_square(x, y, zoom) -> geojson.MultiPolygon: + async def _create_square(x, y, zoom, db) -> geojson.MultiPolygon: """ - Function for creating a geojson.MultiPolygon square representing a single OSM tile grid square - :param x: osm tile grid x - :param y: osm tile grid y - :param zoom: osm tile grid zoom level - :return: geojson.MultiPolygon in EPSG:4326 + Refactored function to create a geojson.MultiPolygon square using raw SQL with encode databases. """ - # Maximum resolution MAXRESOLUTION = 156543.0339 - - # X/Y axis limit max = MAXRESOLUTION * 256 / 2 - - # calculate extents step = max / (2 ** (zoom - 1)) + xmin = x * step - max ymin = y * step - max xmax = (x + 1) * step - max ymax = (y + 1) * step - max - # make a shapely multipolygon + # Create the MultiPolygon object multipolygon = MultiPolygon( [Polygon([(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)])] ) - # use the database to transform the geometry from 3857 to 4326 - transformed_geometry = ST_Transform(shape.from_shape(multipolygon, 3857), 4326) + # Convert MultiPolygon to WKT + multipolygon_wkt = multipolygon.wkt + + # Query to transform and get the GeoJSON + create_square_query = """ + SELECT ST_AsGeoJSON( + ST_Transform( + ST_SetSRID( + ST_Multi(ST_GeomFromText(:multipolygon_geometry)), + 3857 + ), + 4326 + ) + ) AS geojson + """ + # Use the WKT version of the multipolygon in the SQL query + square_geojson_str = await db.fetch_val( + create_square_query, values={"multipolygon_geometry": multipolygon_wkt} + ) - # use DB to get the geometry as geojson - with db.engine.connect() as conn: - return geojson.loads( - conn.execute(transformed_geometry.ST_AsGeoJSON()).scalar() - ) + # Convert the result back to GeoJSON and return + return geojson.loads(square_geojson_str) @staticmethod - def _create_split_tasks_from_geometry(task) -> list: + async def _create_split_tasks_from_geometry(task, db) -> list: + """ + Splits a task into 4 smaller tasks based on its geometry (not OSM tile). """ - Splits a task into 4 smaller tasks based purely on the task's geometry rather than - an OSM tile identified by x, y, zoom - :return: list of {geojson.Feature} + task_query = """ + SELECT ST_AsGeoJSON(geometry) AS geometry + FROM tasks + WHERE id = :task_id AND project_id = :project_id """ - # Load the task's geometry and calculate its centroid and bbox - query = db.session.query( - Task.id, Task.geometry.ST_AsGeoJSON().label("geometry") - ).filter(Task.id == task.id, Task.project_id == task.project_id) - task_geojson = geojson.loads(query[0].geometry) + task_geojson_str = await db.fetch_val( + task_query, values={"task_id": task.id, "project_id": task.project_id} + ) + task_geojson = geojson.loads(task_geojson_str) geometry = shapely_shape(task_geojson) centroid = geometry.centroid minx, miny, maxx, maxy = geometry.bounds - # split geometry in half vertically, then split those halves in half horizontally + # split geometry in half vertically, then horizontally split_geometries = [] vertical_dividing_line = LineString([(centroid.x, miny), (centroid.x, maxy)]) horizontal_dividing_line = LineString([(minx, centroid.y), (maxx, centroid.y)]) @@ -127,18 +133,29 @@ def _create_split_tasks_from_geometry(task) -> list: # convert split geometries into GeoJSON features expected by Task split_features = [] for split_geometry in split_geometries: - feature = geojson.Feature() - # Tasks expect multipolygons. Convert and use the database to get as GeoJSON - multipolygon_geometry = shape.from_shape(split_geometry, 4326) - with db.engine.connect() as conn: - feature.geometry = geojson.loads( - conn.execute(multipolygon_geometry.ST_AsGeoJSON()).scalar() - ) + multipolygon_geometry_wkt = split_geometry.wkt + multipolygon_as_geojson_query = """ + SELECT ST_AsGeoJSON( + ST_Transform( + ST_SetSRID( + ST_Multi(ST_GeomFromText(:multipolygon_geometry)), + 4326 + ), + 4326 + ) + ) AS geojson + """ + feature_geojson = await db.fetch_val( + multipolygon_as_geojson_query, + values={"multipolygon_geometry": multipolygon_geometry_wkt}, + ) + feature = geojson.Feature(geometry=geojson.loads(feature_geojson)) feature.properties["x"] = None feature.properties["y"] = None feature.properties["zoom"] = None feature.properties["isSquare"] = False split_features.append(feature) + return split_features @staticmethod @@ -162,106 +179,214 @@ def _as_halves(geometries, centroid, axis) -> list: return (MultiPolygon(first_half), MultiPolygon(second_half)) @staticmethod - def split_task(split_task_dto: SplitTaskDTO) -> TaskDTOs: + async def delete_task_and_related_records(task_id: int, project_id: int, db): """ - Replaces a task square with 4 smaller tasks at the next OSM tile grid zoom level - Validates that task is: - - locked for mapping by current user - :param split_task_dto: - :return: new tasks in a DTO + Deletes a task and all its related records (task_mapping_issues, task_invalidation_history, task_history) + by task_id and project_id. + + Args: + task_id (int): The ID of the task. + project_id (int): The ID of the project. + db: The database connection object (asyncpg, databases, etc.). """ - # get the task to be split - original_task = Task.get(split_task_dto.task_id, split_task_dto.project_id) - if original_task is None: - raise NotFound(sub_code="TASK_NOT_FOUND", task_id=split_task_dto.task_id) + # Delete related messages + await db.execute( + """ + DELETE FROM messages + WHERE task_id = :task_id AND project_id = :project_id + """, + values={"task_id": task_id, "project_id": project_id}, + ) + + # Delete related task_mapping_issues records + await db.execute( + """ + DELETE FROM task_mapping_issues + WHERE task_history_id IN ( + SELECT id FROM task_history WHERE task_id = :task_id AND project_id = :project_id + ) + """, + values={"task_id": task_id, "project_id": project_id}, + ) + + # Delete related task_invalidation_history records + await db.execute( + """ + DELETE FROM task_invalidation_history + WHERE task_id = :task_id AND project_id = :project_id + """, + values={"task_id": task_id, "project_id": project_id}, + ) + + # Delete related task_history records + await db.execute( + """ + DELETE FROM task_history + WHERE task_id = :task_id AND project_id = :project_id + """, + values={"task_id": task_id, "project_id": project_id}, + ) - original_geometry = shape.to_shape(original_task.geometry) + # Finally, delete the task itself + await db.execute( + """ + DELETE FROM tasks WHERE id = :task_id AND project_id = :project_id + """, + values={"task_id": task_id, "project_id": project_id}, + ) - # Fetch the task geometry in meters - with db.engine.connect() as conn: - original_task_area_m = conn.execute( - ST_Area(ST_GeogFromWKB(original_task.geometry)) - ).scalar() + @staticmethod + async def split_task(split_task_dto: SplitTaskDTO, db: Database) -> list: + original_task = await Task.get( + split_task_dto.task_id, split_task_dto.project_id, db + ) + if not original_task: + raise SplitServiceError("TASK_NOT_FOUND- Task not found") + original_geometry = shape.to_shape( + WKBElement(original_task["geometry"], srid=4326) + ) + + query = """ + SELECT ST_Area(ST_GeogFromWKB(geometry)) + FROM tasks + WHERE id = :task_id AND project_id = :project_id + """ + original_task_area_m = await db.fetch_val( + query, + values={ + "task_id": split_task_dto.task_id, + "project_id": split_task_dto.project_id, + }, + ) if ( - original_task.zoom and original_task.zoom >= 18 + original_task["zoom"] and original_task["zoom"] >= 18 ) or original_task_area_m < 25000: raise SplitServiceError("SmallToSplit- Task is too small to be split") - # check its locked for mapping by the current user - if TaskStatus(original_task.task_status) != TaskStatus.LOCKED_FOR_MAPPING: + if original_task["task_status"] != TaskStatus.LOCKED_FOR_MAPPING.value: raise SplitServiceError( "LockToSplit- Status must be LOCKED_FOR_MAPPING to split" ) - - if original_task.locked_by != split_task_dto.user_id: + if original_task["locked_by"] != split_task_dto.user_id: raise SplitServiceError( "SplitOtherUserTask- Attempting to split a task owned by another user" ) - # create new geometries from the task geometry - try: - new_tasks_geojson = SplitService._create_split_tasks( - original_task.x, original_task.y, original_task.zoom, original_task - ) - except Exception as e: - raise SplitServiceError(f"Error splitting task{str(e)}") + # Split the task geometry into smaller tasks + new_tasks_geojson = await SplitService._create_split_tasks( + original_task["x"], + original_task["y"], + original_task["zoom"], + original_task, + db, + ) - # create new tasks from the new geojson - i = Task.get_max_task_id_for_project(split_task_dto.project_id) - new_tasks = [] + # Fetch the highest task ID for the project + i = await Task.get_max_task_id_for_project(split_task_dto.project_id, db) new_tasks_dto = [] + for new_task_geojson in new_tasks_geojson: - # Sanity check: ensure the new task geometry intersects the original task geometry - new_geometry = shapely_shape(new_task_geojson.geometry) + # Ensure the new task geometry intersects the original geometry + new_geometry = shapely_shape(new_task_geojson["geometry"]) if not new_geometry.intersects(original_geometry): raise InvalidGeoJson( "SplitGeoJsonError- New split task does not intersect original task" ) - # insert new tasks into database - i = i + 1 + # Insert new task into database + i += 1 new_task = Task.from_geojson_feature(i, new_task_geojson) - new_task.project_id = split_task_dto.project_id - new_task.task_status = TaskStatus.READY.value - new_task.create() - new_task.task_history.extend(original_task.copy_task_history()) - if new_task.task_history: - new_task.clear_task_lock() # since we just copied the lock - new_task.set_task_history( - TaskAction.STATE_CHANGE, split_task_dto.user_id, None, TaskStatus.SPLIT + task_geojson_str = geojson.dumps(new_geometry) + task_values = { + "id": new_task.id, + "project_id": split_task_dto.project_id, + "x": new_task.x, + "y": new_task.y, + "zoom": new_task.zoom, + "is_square": new_task.is_square, + "task_status": TaskStatus.READY.value, + "geojson": task_geojson_str, + } + + query = """ + INSERT INTO tasks ( + id, project_id, x, y, zoom, is_square, task_status, geometry + ) + VALUES ( + :id, :project_id, :x, :y, :zoom, :is_square, :task_status, + ST_SetSRID(ST_GeomFromGeoJSON(:geojson), 4326) + ) + """ + await db.execute(query, values=task_values) + await Task.copy_task_history( + split_task_dto.task_id, new_task.id, split_task_dto.project_id, db + ) + await Task.clear_task_lock(new_task.id, new_task.project_id, db) + await Task.set_task_history( + task_id=new_task.id, + project_id=split_task_dto.project_id, + user_id=split_task_dto.user_id, + action=TaskAction.STATE_CHANGE, + db=db, + new_state=TaskStatus.SPLIT, ) - new_task.set_task_history( - TaskAction.STATE_CHANGE, split_task_dto.user_id, None, TaskStatus.READY + await Task.set_task_history( + task_id=new_task.id, + project_id=split_task_dto.project_id, + user_id=split_task_dto.user_id, + action=TaskAction.STATE_CHANGE, + db=db, + new_state=TaskStatus.READY, + ) + update_status_query = """ + UPDATE tasks + SET task_status = :task_status + WHERE id = :task_id AND project_id = :project_id + """ + await db.execute( + update_status_query, + values={ + "task_status": TaskStatus.READY.value, + "task_id": new_task.id, + "project_id": split_task_dto.project_id, + }, ) - new_task.task_status = TaskStatus.READY.value - new_tasks.append(new_task) - new_task.update() new_tasks_dto.append( - new_task.as_dto_with_instructions(split_task_dto.preferred_locale) + await Task.as_dto_with_instructions( + new_task.id, + split_task_dto.project_id, + db, + split_task_dto.preferred_locale, + ) ) - # delete original task from the database - try: - original_task.delete() - except Exception: - db.session.rollback() - # Ensure the new tasks are cleaned up - for new_task in new_tasks: - new_task.delete() - db.session.commit() - raise - - # update project task counts - project = Project.get(split_task_dto.project_id) - project.total_tasks = project.tasks.count() - # update bad imagery because we may have split a bad imagery tile - project.tasks_bad_imagery = project.tasks.filter( - Task.task_status == TaskStatus.BADIMAGERY.value - ).count() - project.save() - - # return the new tasks in a DTO + await SplitService.delete_task_and_related_records( + split_task_dto.task_id, split_task_dto.project_id, db + ) + + query = """ + UPDATE projects + SET total_tasks = ( + SELECT COUNT(*) + FROM tasks + WHERE project_id = :project_id + ), + tasks_bad_imagery = ( + SELECT COUNT(*) + FROM tasks + WHERE project_id = :project_id AND task_status = :bad_imagery_status + ) + WHERE id = :project_id + """ + await db.execute( + query, + values={ + "project_id": split_task_dto.project_id, + "bad_imagery_status": TaskStatus.BADIMAGERY.value, + }, + ) + task_dtos = TaskDTOs() task_dtos.tasks = new_tasks_dto return task_dtos diff --git a/backend/services/interests_service.py b/backend/services/interests_service.py index 1fe5a9dc95..e750a72eba 100644 --- a/backend/services/interests_service.py +++ b/backend/services/interests_service.py @@ -1,26 +1,27 @@ -from backend import db - -from sqlalchemy import func +from databases import Database +from fastapi import HTTPException from backend.models.dtos.interests_dto import ( + InterestDTO, InterestRateDTO, InterestRateListDTO, InterestsListDTO, ) -from backend.models.postgis.task import TaskHistory -from backend.models.postgis.interests import ( - Interest, - project_interests, -) +from backend.models.postgis.interests import Interest +from backend.models.postgis.project import Project from backend.services.project_service import ProjectService -from backend.services.users.user_service import UserService class InterestService: @staticmethod - def get(interest_id): - interest = InterestService.get_by_id(interest_id) - return interest.as_dto() + async def get(interest_id: int, db: Database) -> InterestDTO: + query = """ + SELECT id, name + FROM interests + WHERE id = :interest_id + """ + interest_dto = await db.fetch_one(query, {"interest_id": interest_id}) + return interest_dto @staticmethod def get_by_id(interest_id): @@ -28,35 +29,102 @@ def get_by_id(interest_id): return interest @staticmethod - def get_by_name(name): - interest = Interest.get_by_name(name) - return interest + async def create(interest_name: str, db: Database) -> InterestDTO: + query = """ + INSERT INTO interests (name) + VALUES (:name) + RETURNING id; + """ + values = {"name": interest_name} + interest_id = await db.execute(query, values) + + query_select = """ + SELECT id, name + FROM interests + WHERE id = :id + """ + interest_dto = await db.fetch_one(query_select, {"id": interest_id}) + return interest_dto @staticmethod - def create(interest_name): - interest_model = Interest(name=interest_name) - interest_model.create() - return interest_model.as_dto() + async def update(interest_id: int, interest_dto: InterestDTO, db: Database): + query = """ + UPDATE interests + SET name = :name + WHERE id = :interest_id + """ + values = {"name": interest_dto.name} + await db.execute(query, {**values, "interest_id": interest_id}) + + query_select = """ + SELECT id, name + FROM interests + WHERE id = :id + """ + updated_interest_dto = await db.fetch_one(query_select, {"id": interest_id}) + return updated_interest_dto @staticmethod - def update(interest_id, new_interest_dto): - interest = InterestService.get_by_id(interest_id) - interest.update(new_interest_dto) - return interest.as_dto() + async def get_all_interests(db: Database) -> InterestsListDTO: + query = """ + SELECT id, name + FROM interests + """ + results = await db.fetch_all(query) + + interest_list_dto = InterestsListDTO() + for record in results: + interest_dto = InterestDTO(**record) + interest_list_dto.interests.append(interest_dto) + return interest_list_dto @staticmethod - def get_all_interests() -> InterestsListDTO: - return Interest.get_all_interests() + async def delete(interest_id: int, db: Database): + check_user_association_query = """ + SELECT 1 + FROM user_interests + WHERE interest_id = :interest_id + LIMIT 1; + """ + + user_associated = await db.fetch_one( + check_user_association_query, {"interest_id": interest_id} + ) + if user_associated: + raise HTTPException( + status_code=500, + detail="Interest is associated with a user and cannot be deleted.", + ) - @staticmethod - def delete(interest_id): - interest = InterestService.get_by_id(interest_id) - interest.delete() + check_project_association_query = """ + SELECT 1 + FROM project_interests + WHERE interest_id = :interest_id + LIMIT 1; + """ + project_associated = await db.fetch_one( + check_project_association_query, {"interest_id": interest_id} + ) + if project_associated: + raise HTTPException( + status_code=500, + detail="Interest is associated with a project and cannot be deleted.", + ) + + query = """ + DELETE FROM interests + WHERE id = :interest_id; + """ + try: + async with db.transaction(): + await db.execute(query, {"interest_id": interest_id}) + except Exception as e: + raise HTTPException(status_code=500, detail="Deletion failed") from e @staticmethod - def create_or_update_project_interests(project_id, interests): - project = ProjectService.get_project_by_id(project_id) - project.create_or_update_interests(interests) + async def create_or_update_project_interests(project_id, interests, db: Database): + project = await ProjectService.get_project_by_id(project_id, db) + project = await Project.create_or_update_interests(project, interests, db) # Return DTO. dto = InterestsListDTO() @@ -65,39 +133,68 @@ def create_or_update_project_interests(project_id, interests): return dto @staticmethod - def create_or_update_user_interests(user_id, interests): - user = UserService.get_user_by_id(user_id) - user.create_or_update_interests(interests) + async def create_or_update_user_interests(user_id, interests_ids, db: Database): + """ + Create or update the user's interests by directly interacting with the database. + """ + async with db.transaction(): + delete_query = """ + DELETE FROM user_interests WHERE user_id = :user_id + """ + await db.execute(delete_query, {"user_id": user_id}) + insert_query = """ + INSERT INTO user_interests (user_id, interest_id) + VALUES (:user_id, :interest_id) + """ + values = [ + {"user_id": user_id, "interest_id": interest_id} + for interest_id in interests_ids + ] + await db.execute_many(insert_query, values) + return await InterestService.get_user_interests(user_id, db) - # Return DTO. + @staticmethod + async def get_user_interests(user_id, db: Database) -> InterestsListDTO: + """ + Fetch the updated interests for the user and return the DTO. + """ + query = """ + SELECT i.id, i.name + FROM interests i + JOIN user_interests ui ON i.id = ui.interest_id + WHERE ui.user_id = :user_id + """ + rows = await db.fetch_all(query, {"user_id": user_id}) dto = InterestsListDTO() - dto.interests = [i.as_dto() for i in user.interests] - + dto.interests = [InterestDTO(id=row["id"], name=row["name"]) for row in rows] return dto @staticmethod - def compute_contributions_rate(user_id): - # 1. Get all projects that user has contributed. - stmt = ( - TaskHistory.query.with_entities(TaskHistory.project_id) - .distinct() - .filter(TaskHistory.user_id == user_id) - .subquery() - ) + async def compute_contributions_rate(user_id: int, db: Database): + stmt = """ + SELECT DISTINCT project_id + FROM task_history + WHERE user_id = :user_id + """ + project_ids = await db.fetch_all(stmt, values={"user_id": user_id}) + + if not project_ids: + return InterestRateListDTO() + + project_ids_list = [row["project_id"] for row in project_ids] + + query = """ + SELECT i.name, COUNT(pi.interest_id) / SUM(COUNT(pi.interest_id)) OVER() as rate + FROM project_interests pi + JOIN interests i ON i.id = pi.interest_id + WHERE pi.project_id = ANY(:project_ids) + GROUP BY pi.interest_id, i.name + """ + res = await db.fetch_all(query, values={"project_ids": project_ids_list}) - res = ( - db.session.query( - Interest.name, - func.count(project_interests.c.interest_id) - / func.sum(func.count(project_interests.c.interest_id)).over(), - ) - .group_by(project_interests.c.interest_id, Interest.name) - .filter(project_interests.c.project_id.in_(stmt)) - .join(Interest, Interest.id == project_interests.c.interest_id) - ) - - rates = [InterestRateDTO({"name": r[0], "rate": r[1]}) for r in res.all()] results = InterestRateListDTO() - results.rates = rates + + for r in res: + results.rates.append(InterestRateDTO(name=r["name"], rate=r["rate"])) return results diff --git a/backend/services/license_service.py b/backend/services/license_service.py index 307e3d6a71..78457de4c6 100644 --- a/backend/services/license_service.py +++ b/backend/services/license_service.py @@ -1,10 +1,14 @@ +from databases import Database +from fastapi import HTTPException + +from backend.exceptions import NotFound from backend.models.dtos.licenses_dto import LicenseDTO, LicenseListDTO from backend.models.postgis.licenses import License class LicenseService: @staticmethod - def get_license(license_id: int) -> License: + def get_license(license_id: int, db: Database) -> License: """ Get task from DB :raises: NotFound @@ -13,31 +17,81 @@ def get_license(license_id: int) -> License: return map_license @staticmethod - def get_license_as_dto(license_id: int) -> LicenseDTO: + async def get_license_as_dto(license_id: int, db: Database) -> LicenseDTO: """Get License from DB""" - map_license = LicenseService.get_license(license_id) - return map_license.as_dto() + query = """ + SELECT id AS "licenseId", name, description, plain_text AS "plainText" + FROM licenses + WHERE id = :license_id + """ + license_dto = await db.fetch_one(query, {"license_id": license_id}) + + if license_dto is None: + raise NotFound(sub_code="LICENSE_NOT_FOUND", license_id=license_id) + return LicenseDTO(**license_dto) @staticmethod - def create_licence(license_dto: LicenseDTO) -> int: + async def create_license(license_dto: LicenseDTO, db: Database) -> int: """Create License in DB""" - new_licence_id = License.create_from_dto(license_dto) - return new_licence_id + new_license_id = await License.create_from_dto(license_dto, db) + return new_license_id @staticmethod - def update_licence(license_dto: LicenseDTO) -> LicenseDTO: + async def update_license( + license_dto: LicenseDTO, license_id: int, db: Database + ) -> LicenseDTO: """Create License in DB""" - map_license = LicenseService.get_license(license_dto.license_id) - map_license.update_license(license_dto) - return map_license.as_dto() + + query = """ + UPDATE licenses + SET name = :name, description = :description, plain_text = :plain_text + WHERE id = :license_id + """ + + values = { + "name": license_dto.name, + "description": license_dto.description, + "plain_text": license_dto.plain_text, + } + await db.execute(query, values={**values, "license_id": license_id}) @staticmethod - def delete_license(license_id: int): + async def delete_license(license_id: int, db: Database): """Delete specified license""" - map_license = LicenseService.get_license(license_id) - map_license.delete() + check_query = """ + SELECT 1 + FROM projects + WHERE license_id = :license_id + LIMIT 1; + """ + associated = await db.fetch_one(check_query, {"license_id": license_id}) + if associated: + raise HTTPException( + status_code=500, + detail="Cannot delete the license as it is associated with a project.", + ) + + query = """ + DELETE FROM licenses + WHERE id = :license_id; + """ + try: + async with db.transaction(): + await db.execute(query, {"license_id": license_id}) + except Exception as e: + raise HTTPException(status_code=500, detail="Deletion failed") from e @staticmethod - def get_all_licenses() -> LicenseListDTO: - """Get all licenses in DB""" - return License.get_all() + async def get_all_licenses(db: Database) -> LicenseListDTO: + """Gets all licenses currently stored""" + query = """ + SELECT id AS "licenseId", name, description, plain_text AS "plainText" + FROM licenses + """ + results = await db.fetch_all(query) + + lic_dto = LicenseListDTO() + for record in results: + l_dto = LicenseDTO(**record) + lic_dto.licenses.append(l_dto) + return lic_dto diff --git a/backend/services/mapping_issues_service.py b/backend/services/mapping_issues_service.py index 1644976f93..3a74202f41 100644 --- a/backend/services/mapping_issues_service.py +++ b/backend/services/mapping_issues_service.py @@ -1,16 +1,20 @@ +from databases import Database + from backend.exceptions import NotFound -from backend.models.postgis.mapping_issues import MappingIssueCategory from backend.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO +from backend.models.postgis.mapping_issues import MappingIssueCategory class MappingIssueCategoryService: @staticmethod - def get_mapping_issue_category(category_id: int) -> MappingIssueCategory: + async def get_mapping_issue_category( + category_id: int, db: Database + ) -> MappingIssueCategory: """ Get MappingIssueCategory from DB :raises: NotFound """ - category = MappingIssueCategory.get_by_id(category_id) + category = await MappingIssueCategory.get_by_id(category_id, db) if category is None: raise NotFound(sub_code="ISSUE_CATEGORY_NOT_FOUND", category_id=category_id) @@ -18,37 +22,45 @@ def get_mapping_issue_category(category_id: int) -> MappingIssueCategory: return category @staticmethod - def get_mapping_issue_category_as_dto(category_id: int) -> MappingIssueCategoryDTO: + async def get_mapping_issue_category_as_dto( + category_id: int, db: Database + ) -> MappingIssueCategoryDTO: """Get MappingIssueCategory from DB""" - category = MappingIssueCategoryService.get_mapping_issue_category(category_id) - return category.as_dto() + category = await MappingIssueCategoryService.get_mapping_issue_category( + category_id, db + ) + return MappingIssueCategory.as_dto(category) @staticmethod - def create_mapping_issue_category(category_dto: MappingIssueCategoryDTO) -> int: + async def create_mapping_issue_category( + category_dto: MappingIssueCategoryDTO, db: Database + ) -> int: """Create MappingIssueCategory in DB""" - new_mapping_issue_category_id = MappingIssueCategory.create_from_dto( - category_dto + new_mapping_issue_category_id = await MappingIssueCategory.create_from_dto( + category_dto, db ) return new_mapping_issue_category_id @staticmethod - def update_mapping_issue_category( - category_dto: MappingIssueCategoryDTO, + async def update_mapping_issue_category( + category_dto: MappingIssueCategoryDTO, db: Database ) -> MappingIssueCategoryDTO: """Create MappingIssueCategory in DB""" - category = MappingIssueCategoryService.get_mapping_issue_category( - category_dto.category_id + category = await MappingIssueCategoryService.get_mapping_issue_category( + category_dto.category_id, db ) - category.update_category(category_dto) - return category.as_dto() + await MappingIssueCategory.update_category(category, category_dto, db) + return MappingIssueCategory.as_dto(category) @staticmethod - def delete_mapping_issue_category(category_id: int): + async def delete_mapping_issue_category(category_id: int, db: Database): """Delete specified license""" - category = MappingIssueCategoryService.get_mapping_issue_category(category_id) - category.delete() + category = await MappingIssueCategoryService.get_mapping_issue_category( + category_id, db + ) + await MappingIssueCategory.delete(category, db) @staticmethod - def get_all_mapping_issue_categories(include_archived): + async def get_all_mapping_issue_categories(include_archived, db): """Get all mapping issue categories""" - return MappingIssueCategory.get_all_categories(include_archived) + return await MappingIssueCategory.get_all_categories(include_archived, db) diff --git a/backend/services/mapping_service.py b/backend/services/mapping_service.py index ca8c1a47bd..c0ad17a914 100644 --- a/backend/services/mapping_service.py +++ b/backend/services/mapping_service.py @@ -1,20 +1,24 @@ import datetime import xml.etree.ElementTree as ET -from flask import current_app -from geoalchemy2 import shape +from databases import Database +from fastapi import BackgroundTasks + +from geoalchemy2 import WKBElement +from geoalchemy2.shape import to_shape +from loguru import logger from backend.exceptions import NotFound from backend.models.dtos.mapping_dto import ( ExtendLockTimeDTO, - TaskDTO, - MappedTaskDTO, LockTaskDTO, + MappedTaskDTO, StopMappingTaskDTO, TaskCommentDTO, + TaskDTO, ) from backend.models.postgis.statuses import MappingNotAllowed -from backend.models.postgis.task import Task, TaskStatus, TaskHistory, TaskAction +from backend.models.postgis.task import Task, TaskAction, TaskHistory, TaskStatus from backend.models.postgis.utils import UserLicenseError from backend.services.messaging.message_service import MessageService from backend.services.project_service import ProjectService @@ -25,75 +29,84 @@ class MappingServiceError(Exception): """Custom Exception to notify callers an error occurred when handling mapping""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class MappingService: @staticmethod - def get_task(task_id: int, project_id: int) -> Task: + async def get_task(task_id: int, project_id: int, db: Database) -> Task: """ Get task from DB :raises: NotFound """ - task = Task.get(task_id, project_id) - - if task is None: + task = await Task.get(task_id, project_id, db) + if not task: raise NotFound( sub_code="TASK_NOT_FOUND", project_id=project_id, task_id=task_id ) - return task @staticmethod - def get_task_as_dto( + async def get_task_as_dto( task_id: int, project_id: int, + db, preferred_local: str = "en", ) -> TaskDTO: """Get task as DTO for transmission over API""" - task = MappingService.get_task(task_id, project_id) - task_dto = task.as_dto_with_instructions(preferred_local) + task = await Task.exists(task_id, project_id, db) + if not task: + raise NotFound( + sub_code="TASK_NOT_FOUND", project_id=project_id, task_id=task_id + ) + task_dto = await Task.as_dto_with_instructions( + task_id, project_id, db, preferred_local + ) return task_dto @staticmethod - def _is_task_undoable(logged_in_user_id: int, task: Task) -> bool: + async def _is_task_undoable( + logged_in_user_id: int, task: dict, db: Database + ) -> bool: """Determines if the current task status can be undone by the logged in user""" - # Test to see if user can undo status on this task if logged_in_user_id and TaskStatus(task.task_status) not in [ TaskStatus.LOCKED_FOR_MAPPING, TaskStatus.LOCKED_FOR_VALIDATION, TaskStatus.READY, ]: - last_action = TaskHistory.get_last_action(task.project_id, task.id) - + last_action = await TaskHistory.get_last_action( + task.project_id, task.id, db + ) # User requesting task made the last change, so they are allowed to undo it. - is_user_permitted, _ = ProjectService.is_user_permitted_to_validate( - task.project_id, logged_in_user_id + is_user_permitted, _ = await ProjectService.is_user_permitted_to_validate( + task.project_id, logged_in_user_id, db ) - if last_action.user_id == int(logged_in_user_id) or is_user_permitted: + if last_action.user_id == logged_in_user_id or is_user_permitted: return True - return False @staticmethod - def lock_task_for_mapping(lock_task_dto: LockTaskDTO) -> TaskDTO: + async def lock_task_for_mapping( + lock_task_dto: LockTaskDTO, db: Database + ) -> TaskDTO: """ Sets the task_locked status to locked so no other user can work on it :param lock_task_dto: DTO with data needed to lock the task :raises TaskServiceError :return: Updated task, or None if not found """ - task = MappingService.get_task(lock_task_dto.task_id, lock_task_dto.project_id) + + task = await MappingService.get_task( + lock_task_dto.task_id, lock_task_dto.project_id, db + ) if task.locked_by != lock_task_dto.user_id: - if not task.is_mappable(): + if not Task.is_mappable(task): raise MappingServiceError( "InvalidTaskState- Task in invalid state for mapping" ) - - user_can_map, error_reason = ProjectService.is_user_permitted_to_map( - lock_task_dto.project_id, lock_task_dto.user_id + user_can_map, error_reason = await ProjectService.is_user_permitted_to_map( + lock_task_dto.project_id, lock_task_dto.user_id, db ) if not user_can_map: if error_reason == MappingNotAllowed.USER_NOT_ACCEPTED_LICENSE: @@ -115,93 +128,135 @@ def lock_task_for_mapping(lock_task_dto: LockTaskDTO) -> TaskDTO: f"{error_reason}- Mapping not allowed because: {error_reason}" ) - task.lock_task_for_mapping(lock_task_dto.user_id) - return task.as_dto_with_instructions(lock_task_dto.preferred_locale) + await Task.lock_task_for_mapping( + lock_task_dto.task_id, lock_task_dto.project_id, lock_task_dto.user_id, db + ) + return await Task.as_dto_with_instructions( + lock_task_dto.task_id, + lock_task_dto.project_id, + db, + lock_task_dto.preferred_locale, + ) @staticmethod - def unlock_task_after_mapping(mapped_task: MappedTaskDTO) -> TaskDTO: + async def unlock_task_after_mapping( + mapped_task: MappedTaskDTO, + db: Database, + background_tasks: BackgroundTasks, + ) -> TaskDTO: """Unlocks the task and sets the task history appropriately""" - task = MappingService.get_task_locked_by_user( - mapped_task.project_id, mapped_task.task_id, mapped_task.user_id + # Fetch the task locked by the user + task = await MappingService.get_task_locked_by_user( + mapped_task.project_id, mapped_task.task_id, mapped_task.user_id, db ) - + # Validate the new state new_state = TaskStatus[mapped_task.status.upper()] - if new_state not in [ TaskStatus.MAPPED, TaskStatus.BADIMAGERY, TaskStatus.READY, ]: raise MappingServiceError( - "InvalidUnlockState- Can only set status to MAPPED, BADIMAGERY, READY after mapping" + "InvalidUnlockState - Can only set status to MAPPED, BADIMAGERY, READY after mapping" ) - - # Update stats around the change of state - last_state = TaskHistory.get_last_status( - mapped_task.project_id, mapped_task.task_id + last_state = await TaskHistory.get_last_status( + mapped_task.project_id, mapped_task.task_id, db ) - StatsService.update_stats_after_task_state_change( - mapped_task.project_id, mapped_task.user_id, last_state, new_state + await StatsService.update_stats_after_task_state_change( + mapped_task.project_id, mapped_task.user_id, last_state, new_state, db ) - if mapped_task.comment: - # Parses comment to see if any users have been @'d - MessageService.send_message_after_comment( + await MessageService.send_message_after_comment( mapped_task.user_id, mapped_task.comment, task.id, mapped_task.project_id, + db, ) + # Unlock the task and change its state + await Task.unlock_task( + task_id=mapped_task.task_id, + project_id=mapped_task.project_id, + user_id=mapped_task.user_id, + new_state=new_state, + db=db, + comment=mapped_task.comment, + ) + # Send email on project progress + background_tasks.add_task( + ProjectService.send_email_on_project_progress, mapped_task.project_id + ) - task.unlock_task(mapped_task.user_id, new_state, mapped_task.comment) - ProjectService.send_email_on_project_progress(mapped_task.project_id) - return task.as_dto_with_instructions(mapped_task.preferred_locale) + return await Task.as_dto_with_instructions( + task_id=mapped_task.task_id, + project_id=mapped_task.project_id, + db=db, + preferred_locale=mapped_task.preferred_locale, + ) @staticmethod - def stop_mapping_task(stop_task: StopMappingTaskDTO) -> TaskDTO: + async def stop_mapping_task(stop_task: StopMappingTaskDTO, db: Database) -> TaskDTO: """Unlocks the task and revert the task status to the last one""" - task = MappingService.get_task_locked_by_user( - stop_task.project_id, stop_task.task_id, stop_task.user_id + task = await MappingService.get_task_locked_by_user( + stop_task.project_id, stop_task.task_id, stop_task.user_id, db ) if stop_task.comment: # Parses comment to see if any users have been @'d - MessageService.send_message_after_comment( - stop_task.user_id, stop_task.comment, task.id, stop_task.project_id + await MessageService.send_message_after_comment( + stop_task.user_id, stop_task.comment, task.id, stop_task.project_id, db ) - - task.reset_lock(stop_task.user_id, stop_task.comment) - return task.as_dto_with_instructions(stop_task.preferred_locale) + await Task.reset_lock( + task.id, + stop_task.project_id, + task.task_status, + stop_task.user_id, + stop_task.comment, + db, + ) + return await Task.as_dto_with_instructions( + task.id, stop_task.project_id, db, stop_task.preferred_locale + ) @staticmethod - def get_task_locked_by_user(project_id: int, task_id: int, user_id: int) -> Task: + async def get_task_locked_by_user( + project_id: int, task_id: int, user_id: int, db: Database + ): + """Returns task specified by project id and task id if found and locked for mapping by user""" + query = """ + SELECT * FROM tasks + WHERE id = :task_id AND project_id = :project_id """ - Returns task specified by project id and task id if found and locked for mapping by user - :raises: MappingServiceError - """ - task = MappingService.get_task(task_id, project_id) + task = await db.fetch_one( + query, values={"task_id": task_id, "project_id": project_id} + ) if task is None: raise NotFound( - sub_code="TASK_NOT_FOUND", project_id=project_id, task_id=task_id + status_code=404, + sub_code="TASK_NOT_FOUND", + project_id=project_id, + task_id=task_id, ) - current_state = TaskStatus(task.task_status) - if current_state != TaskStatus.LOCKED_FOR_MAPPING: + + if task.task_status != TaskStatus.LOCKED_FOR_MAPPING.value: raise MappingServiceError( "LockBeforeUnlocking- Status must be LOCKED_FOR_MAPPING to unlock" ) + if task.locked_by != user_id: raise MappingServiceError( "TaskNotOwned- Attempting to unlock a task owned by another user" ) + return task @staticmethod - def add_task_comment(task_comment: TaskCommentDTO) -> TaskDTO: + async def add_task_comment(task_comment: TaskCommentDTO, db: Database) -> TaskDTO: """Adds the comment to the task history""" # Check if project exists - ProjectService.exists(task_comment.project_id) + await ProjectService.exists(task_comment.project_id, db) - task = Task.get(task_comment.task_id, task_comment.project_id) + task = await Task.get(task_comment.task_id, task_comment.project_id, db) if task is None: raise NotFound( sub_code="TASK_NOT_FOUND", @@ -209,18 +264,33 @@ def add_task_comment(task_comment: TaskCommentDTO) -> TaskDTO: task_id=task_comment.task_id, ) - task.set_task_history( - TaskAction.COMMENT, task_comment.user_id, task_comment.comment + await Task.set_task_history( + task_id=task_comment.task_id, + project_id=task_comment.project_id, + user_id=task_comment.user_id, + action=TaskAction.COMMENT, + db=db, + comment=task_comment.comment, ) # Parse comment to see if any users have been @'d - MessageService.send_message_after_comment( - task_comment.user_id, task_comment.comment, task.id, task_comment.project_id + await MessageService.send_message_after_comment( + task_comment.user_id, + task_comment.comment, + task.id, + task_comment.project_id, + db, + ) + return await Task.as_dto_with_instructions( + task_comment.task_id, + task_comment.project_id, + db, + task_comment.preferred_locale, ) - task.update() - return task.as_dto_with_instructions(task_comment.preferred_locale) @staticmethod - def generate_gpx(project_id: int, task_ids_str: str, timestamp=None): + async def generate_gpx( + project_id: int, task_ids_str: str, db: Database, timestamp=None + ): """ Creates a GPX file for supplied tasks. Timestamp is for unit testing only. You can use the following URL to test locally: @@ -253,25 +323,29 @@ def generate_gpx(project_id: int, task_ids_str: str, timestamp=None): # Create trk element trk = ET.Element("trk") root.append(trk) - ET.SubElement( - trk, "name" - ).text = f"Task for project {project_id}. Do not edit outside of this area!" + ET.SubElement(trk, "name").text = ( + f"Task for project {project_id}. Do not edit outside of this area!" + ) # Construct trkseg elements if task_ids_str is not None: task_ids = list(map(int, task_ids_str.split(","))) - tasks = Task.get_tasks(project_id, task_ids) + tasks = await Task.get_tasks(project_id, task_ids, db) if not tasks or len(tasks) == 0: raise NotFound( sub_code="TASKS_NOT_FOUND", project_id=project_id, task_ids=task_ids ) else: - tasks = Task.get_all_tasks(project_id) + tasks = await Task.get_all_tasks(project_id, db) if not tasks or len(tasks) == 0: raise NotFound(sub_code="TASKS_NOT_FOUND", project_id=project_id) for task in tasks: - task_geom = shape.to_shape(task.geometry) + # task_geom = shape.to_shape(task.geometry) + if isinstance(task["geometry"], (bytes, str)): + task_geom = to_shape(WKBElement(task["geometry"], srid=4326)) + else: + raise ValueError("Invalid geometry format") for poly in task_geom.geoms: trkseg = ET.SubElement(trk, "trkseg") for point in poly.exterior.coords: @@ -280,8 +354,6 @@ def generate_gpx(project_id: int, task_ids_str: str, timestamp=None): "trkpt", attrib=dict(lon=str(point[0]), lat=str(point[1])), ) - - # Append wpt elements to end of doc wpt = ET.Element( "wpt", attrib=dict(lon=str(point[0]), lat=str(point[1])) ) @@ -291,7 +363,7 @@ def generate_gpx(project_id: int, task_ids_str: str, timestamp=None): return xml_gpx @staticmethod - def generate_osm_xml(project_id: int, task_ids_str: str) -> str: + async def generate_osm_xml(project_id: int, task_ids_str: str, db: Database) -> str: """Generate xml response suitable for loading into JOSM. A sample output file is in /backend/helpers/testfiles/osm-sample.xml""" # Note XML created with upload No to ensure it will be rejected by OSM if uploaded by mistake @@ -299,22 +371,24 @@ def generate_osm_xml(project_id: int, task_ids_str: str) -> str: "osm", attrib=dict(version="0.6", upload="never", creator="HOT Tasking Manager"), ) - if task_ids_str: task_ids = list(map(int, task_ids_str.split(","))) - tasks = Task.get_tasks(project_id, task_ids) + tasks = await Task.get_tasks(project_id, task_ids, db) if not tasks or len(tasks) == 0: raise NotFound( sub_code="TASKS_NOT_FOUND", project_id=project_id, task_ids=task_ids ) else: - tasks = Task.get_all_tasks(project_id) + tasks = await Task.get_all_tasks(project_id, db) if not tasks or len(tasks) == 0: raise NotFound(sub_code="TASKS_NOT_FOUND", project_id=project_id) fake_id = -1 # We use fake-ids to ensure XML will not be validated by OSM for task in tasks: - task_geom = shape.to_shape(task.geometry) + if isinstance(task["geometry"], (bytes, str)): + task_geom = to_shape(WKBElement(task["geometry"], srid=4326)) + else: + raise ValueError("Invalid geometry format") way = ET.SubElement( root, "way", @@ -340,16 +414,19 @@ def generate_osm_xml(project_id: int, task_ids_str: str) -> str: return xml_gpx @staticmethod - def undo_mapping( - project_id: int, task_id: int, user_id: int, preferred_locale: str = "en" + async def undo_mapping( + project_id: int, + task_id: int, + user_id: int, + db: Database, + preferred_locale: str = "en", ) -> TaskDTO: """Allows a user to Undo the task state they updated""" - task = MappingService.get_task(task_id, project_id) - if not MappingService._is_task_undoable(user_id, task): + task = await MappingService.get_task(task_id, project_id, db) + if not await MappingService._is_task_undoable(user_id, task, db): raise MappingServiceError( "UndoPermissionError- Undo not allowed for this user" ) - current_state = TaskStatus(task.task_status) # Set the state to the previous state in the workflow if current_state == TaskStatus.VALIDATED: @@ -359,80 +436,110 @@ def undo_mapping( elif current_state == TaskStatus.MAPPED: undo_state = TaskStatus.READY else: - undo_state = TaskHistory.get_last_status(project_id, task_id, True) + undo_state = await TaskHistory.get_last_status( + project_id, task_id, db, True + ) # Refer to last action for user of it. - last_action = TaskHistory.get_last_action(project_id, task_id) + last_action = await TaskHistory.get_last_action(project_id, task_id, db) - StatsService.update_stats_after_task_state_change( - project_id, last_action.user_id, current_state, undo_state, "undo" + await StatsService.update_stats_after_task_state_change( + project_id, last_action.user_id, current_state, undo_state, db, "undo" ) - - task.unlock_task( - user_id, - undo_state, - f"Undo state from {current_state.name} to {undo_state.name}", - True, + await Task.unlock_task( + task_id=task_id, + project_id=project_id, + user_id=user_id, + new_state=undo_state, + db=db, + comment=f"Undo state from {current_state.name} to {undo_state.name}", + undo=True, + ) + return await Task.as_dto_with_instructions( + task_id, project_id, db, preferred_locale ) - # Reset the user who mapped/validated the task - if current_state.name == "MAPPED": - task.mapped_by = None - elif current_state.name == "VALIDATED": - task.validated_by = None - task.update() - return task.as_dto_with_instructions(preferred_locale) @staticmethod - def map_all_tasks(project_id: int, user_id: int): - """Marks all tasks on a project as mapped""" - tasks_to_map = Task.query.filter( - Task.project_id == project_id, - Task.task_status.notin_( - [ - TaskStatus.BADIMAGERY.value, - TaskStatus.MAPPED.value, - TaskStatus.VALIDATED.value, - ] - ), - ).all() + async def map_all_tasks(project_id: int, user_id: int, db: Database): + """Marks all tasks on a project as mapped using raw SQL queries""" + + query = """ + SELECT id, task_status + FROM tasks + WHERE project_id = :project_id + AND task_status NOT IN (:bad_imagery, :mapped, :validated) + """ + tasks_to_map = await db.fetch_all( + query=query, + values={ + "project_id": project_id, + "bad_imagery": TaskStatus.BADIMAGERY.value, + "mapped": TaskStatus.MAPPED.value, + "validated": TaskStatus.VALIDATED.value, + }, + ) for task in tasks_to_map: - if TaskStatus(task.task_status) not in [ + task_id = task["id"] + current_status = TaskStatus(task["task_status"]) + + # Lock the task for mapping if it's not already locked + if current_status not in [ TaskStatus.LOCKED_FOR_MAPPING, TaskStatus.LOCKED_FOR_VALIDATION, ]: - # Only lock tasks that are not already locked to avoid double lock issue - task.lock_task_for_mapping(user_id) - - task.unlock_task(user_id, new_state=TaskStatus.MAPPED) + await Task.lock_task_for_mapping(task_id, project_id, user_id, db) + + # Unlock the task and set its status to MAPPED + await Task.unlock_task( + task_id=task_id, + project_id=project_id, + user_id=user_id, + new_state=TaskStatus.MAPPED, + db=db, + ) - # Set counters to fully mapped - project = ProjectService.get_project_by_id(project_id) - project.tasks_mapped = ( - project.total_tasks - project.tasks_bad_imagery - project.tasks_validated - ) - project.save() + project_update_query = """ + UPDATE projects + SET tasks_mapped = (total_tasks - tasks_bad_imagery - tasks_validated) + WHERE id = :project_id + """ + await db.execute(query=project_update_query, values={"project_id": project_id}) @staticmethod - def reset_all_badimagery(project_id: int, user_id: int): - """Marks all bad imagery tasks ready for mapping""" - badimagery_tasks = Task.query.filter( - Task.task_status == TaskStatus.BADIMAGERY.value, - Task.project_id == project_id, - ).all() + async def reset_all_badimagery(project_id: int, user_id: int, db: Database): + """Marks all bad imagery tasks as ready for mapping and resets the bad imagery counter""" + # Fetch all tasks with status BADIMAGERY for the given project + badimagery_query = """ + SELECT id FROM tasks + WHERE task_status = :task_status AND project_id = :project_id + """ + badimagery_tasks = await db.fetch_all( + query=badimagery_query, + values={ + "task_status": TaskStatus.BADIMAGERY.value, + "project_id": project_id, + }, + ) for task in badimagery_tasks: - task.lock_task_for_mapping(user_id) - task.unlock_task(user_id, new_state=TaskStatus.READY) - - # Reset bad imagery counter - project = ProjectService.get_project_by_id(project_id) - project.tasks_bad_imagery = 0 - project.save() + task_id = task["id"] + await Task.lock_task_for_mapping(task_id, project_id, user_id, db) + await Task.unlock_task(task_id, project_id, user_id, TaskStatus.READY, db) + + # Reset bad imagery counter in the project + reset_query = """ + UPDATE projects + SET tasks_bad_imagery = 0 + WHERE id = :project_id + """ + await db.execute(query=reset_query, values={"project_id": project_id}) @staticmethod - def lock_time_can_be_extended(project_id, task_id, user_id): - task = Task.get(task_id, project_id) + async def lock_time_can_be_extended( + project_id: int, task_id: int, user_id: int, db: Database + ): + task = await Task.get(task_id, project_id, db) if task is None: raise NotFound( sub_code="TASK_NOT_FOUND", project_id=project_id, task_id=task_id @@ -451,31 +558,33 @@ def lock_time_can_be_extended(project_id, task_id, user_id): ) @staticmethod - def extend_task_lock_time(extend_dto: ExtendLockTimeDTO): + async def extend_task_lock_time(extend_dto: ExtendLockTimeDTO, db: Database): """ - Extends expiry time of locked tasks + Extends expiry time of locked tasks. :raises ValidatorServiceError """ - # Loop supplied tasks to check they can all be locked for validation - tasks_to_extend = [] + # Validate each task before extending lock time for task_id in extend_dto.task_ids: - MappingService.lock_time_can_be_extended( - extend_dto.project_id, task_id, extend_dto.user_id + await MappingService.lock_time_can_be_extended( + extend_dto.project_id, task_id, extend_dto.user_id, db ) - tasks_to_extend.append(task_id) - # # Lock all tasks for validation - for task_id in tasks_to_extend: - task = Task.get(task_id, extend_dto.project_id) - action = TaskAction.EXTENDED_FOR_MAPPING - if task.task_status == TaskStatus.LOCKED_FOR_VALIDATION: - action = TaskAction.EXTENDED_FOR_VALIDATION + # Extend lock time for validated tasks + for task_id in extend_dto.task_ids: + task = await Task.get(task_id, extend_dto.project_id, db) + action = ( + TaskAction.EXTENDED_FOR_MAPPING + if task["task_status"] == TaskStatus.LOCKED_FOR_MAPPING + else TaskAction.EXTENDED_FOR_VALIDATION + ) - TaskHistory.update_task_locked_with_duration( + await TaskHistory.update_task_locked_with_duration( task_id, extend_dto.project_id, - TaskStatus(task.task_status), + TaskStatus(task["task_status"]), extend_dto.user_id, + db, + ) + await Task.set_task_history( + task_id, extend_dto.project_id, extend_dto.user_id, action, db ) - task.set_task_history(action, extend_dto.user_id) - task.update() diff --git a/backend/services/mapswipe_service.py b/backend/services/mapswipe_service.py index b41552c3df..ef7eb7f184 100644 --- a/backend/services/mapswipe_service.py +++ b/backend/services/mapswipe_service.py @@ -1,23 +1,22 @@ import json + +import requests + from backend.exceptions import Conflict from backend.models.dtos.partner_stats_dto import ( - GroupedPartnerStatsDTO, - FilteredPartnerStatsDTO, - UserGroupMemberDTO, - UserContributionsDTO, - GeojsonDTO, - GeoContributionsDTO, AreaSwipedByProjectTypeDTO, ContributionsByDateDTO, - ContributionTimeByDateDTO, ContributionsByProjectTypeDTO, + ContributionTimeByDateDTO, + FilteredPartnerStatsDTO, + GeoContributionsDTO, + GeojsonDTO, + GroupedPartnerStatsDTO, OrganizationContributionsDTO, + UserContributionsDTO, + UserGroupMemberDTO, ) -from cachetools import TTLCache, cached -import requests -grouped_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) -filtered_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) MAPSWIPE_API_URL = "https://api.mapswipe.org/graphql/" @@ -138,9 +137,8 @@ def setup_group_dto( self, partner_id: str, group_id: str, resp_body: str ) -> GroupedPartnerStatsDTO: group_stats = json.loads(resp_body)["data"] - group_dto = GroupedPartnerStatsDTO() + group_dto = GroupedPartnerStatsDTO(provider="mapswipe") group_dto.id = partner_id - group_dto.provider = "mapswipe" group_dto.id_inside_provider = group_id if group_stats["userGroup"] is None: @@ -194,16 +192,14 @@ def setup_filtered_dto( to_date: str, resp_body: str, ): - filtered_stats_dto = FilteredPartnerStatsDTO() + filtered_stats_dto = FilteredPartnerStatsDTO(provider="mapswipe") filtered_stats_dto.id = partner_id - filtered_stats_dto.provider = "mapswipe" filtered_stats_dto.id_inside_provider = group_id filtered_stats_dto.from_date = from_date filtered_stats_dto.to_date = to_date filtered_stats = json.loads(resp_body)["data"] - - if filtered_stats["userGroup"] is None: + if filtered_stats is None or filtered_stats["userGroup"] is None: raise Conflict( "INVALID_MAPSWIPE_GROUP_ID", "The mapswipe group ID linked to this partner is invalid. Please contact an admin.", @@ -290,7 +286,6 @@ def setup_filtered_dto( filtered_stats_dto.contributions_by_organization_name = organizations return filtered_stats_dto - @cached(grouped_partner_stats_cache) def fetch_grouped_partner_stats( self, partner_id: int, @@ -316,7 +311,6 @@ def fetch_grouped_partner_stats( group_dto = self.setup_group_dto(partner_id, group_id, resp_body) return group_dto - @cached(filtered_partner_stats_cache) def fetch_filtered_partner_stats( self, partner_id: str, diff --git a/backend/services/messaging/chat_service.py b/backend/services/messaging/chat_service.py index 825be2123f..6ec0d3e27a 100644 --- a/backend/services/messaging/chat_service.py +++ b/backend/services/messaging/chat_service.py @@ -1,34 +1,37 @@ -import threading -from flask import current_app +from databases import Database +from fastapi import BackgroundTasks -from backend import db from backend.exceptions import NotFound from backend.models.dtos.message_dto import ChatMessageDTO, ProjectChatDTO +from backend.models.postgis.project import ProjectStatus from backend.models.postgis.project_chat import ProjectChat from backend.models.postgis.project_info import ProjectInfo +from backend.models.postgis.statuses import TeamRoles from backend.services.messaging.message_service import MessageService -from backend.services.project_service import ProjectService from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_service import ProjectService from backend.services.team_service import TeamService -from backend.models.postgis.statuses import TeamRoles -from backend.models.postgis.project import ProjectStatus class ChatService: @staticmethod - def post_message( - chat_dto: ChatMessageDTO, project_id: int, authenticated_user_id: int + async def post_message( + chat_dto: ChatMessageDTO, + project_id: int, + authenticated_user_id: int, + db: Database, + background_tasks: BackgroundTasks, ) -> ProjectChatDTO: - """Save message to DB and return latest chat""" - current_app.logger.debug("Posting Chat Message") - - project = ProjectService.get_project_by_id(project_id) - project_name = ProjectInfo.get_dto_for_locale( - project_id, project.default_locale - ).name + project = await ProjectService.get_project_by_id(project_id, db) + project_info_dto = await ProjectInfo.get_dto_for_locale( + db, project_id, project.default_locale + ) + project_name = project_info_dto.name is_allowed_user = True - is_manager_permission = ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id + is_manager_permission = ( + await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db + ) ) is_team_member = False @@ -47,8 +50,8 @@ def post_message( TeamRoles.VALIDATOR.value, TeamRoles.MAPPER.value, ] - is_team_member = TeamService.check_team_membership( - project_id, allowed_roles, authenticated_user_id + is_team_member = await TeamService.check_team_membership( + project_id, allowed_roles, authenticated_user_id, db ) if not is_team_member: is_allowed_user = ( @@ -61,28 +64,25 @@ def post_message( ) > 0 ) - if is_manager_permission or is_team_member or is_allowed_user: - chat_message = ProjectChat.create_from_dto(chat_dto) - db.session.commit() - threading.Thread( - target=MessageService.send_message_after_chat, - args=( - chat_dto.user_id, - chat_message.message, - chat_dto.project_id, - project_name, - ), - ).start() - # Ensure we return latest messages after post - return ProjectChat.get_messages(chat_dto.project_id, 1, 5) + chat_message = await ProjectChat.create_from_dto(chat_dto, db) + background_tasks.add_task( + MessageService.send_message_after_chat, + chat_dto.user_id, + chat_message.message, + chat_dto.project_id, + project_name, + ) + return await ProjectChat.get_messages(chat_dto.project_id, db, 1, 5) else: raise ValueError("UserNotPermitted- User not permitted to post Comment") @staticmethod - def get_messages(project_id: int, page: int, per_page: int) -> ProjectChatDTO: + async def get_messages( + project_id: int, db: Database, page: int, per_page: int + ) -> ProjectChatDTO: """Get all messages attached to a project""" - return ProjectChat.get_messages(project_id, page, per_page) + return await ProjectChat.get_messages(project_id, db, page, per_page) @staticmethod def get_project_chat_by_id(project_id: int, comment_id: int) -> ProjectChat: @@ -109,8 +109,11 @@ def get_project_chat_by_id(project_id: int, comment_id: int) -> ProjectChat: return chat_message @staticmethod - def delete_project_chat_by_id(project_id: int, comment_id: int, user_id: int): - """Deletes a message from a project chat + async def delete_project_chat_by_id( + project_id: int, comment_id: int, user_id: int, db: Database + ): + """ + Deletes a message from a project chat ---------------------------------------- :param project_id: The id of the project the message belongs to :param message_id: The message id to delete @@ -122,12 +125,18 @@ def delete_project_chat_by_id(project_id: int, comment_id: int, user_id: int): returns: None """ # Check if project exists - ProjectService.exists(project_id) + await ProjectService.exists(project_id, db) + + # Fetch the chat message + query = """ + SELECT user_id + FROM project_chat + WHERE project_id = :project_id AND id = :comment_id + """ + chat_message = await db.fetch_one( + query, values={"project_id": project_id, "comment_id": comment_id} + ) - chat_message = ProjectChat.query.filter( - ProjectChat.project_id == project_id, - ProjectChat.id == comment_id, - ).one_or_none() if chat_message is None: raise NotFound( sub_code="MESSAGE_NOT_FOUND", @@ -135,15 +144,22 @@ def delete_project_chat_by_id(project_id: int, comment_id: int, user_id: int): project_id=project_id, ) - is_user_allowed = ( - chat_message.user_id == user_id - or ProjectAdminService.is_user_action_permitted_on_project( - user_id, project_id - ) + is_user_allowed = chat_message[ + "user_id" + ] == user_id or await ProjectAdminService.is_user_action_permitted_on_project( + user_id, project_id, db ) + if is_user_allowed: - db.session.delete(chat_message) - db.session.commit() + # Delete the chat message + delete_query = """ + DELETE FROM project_chat + WHERE project_id = :project_id AND id = :comment_id + """ + await db.execute( + delete_query, + values={"project_id": project_id, "comment_id": comment_id}, + ) else: raise ValueError( "DeletePermissionError- User not allowed to delete message" diff --git a/backend/services/messaging/message_service.py b/backend/services/messaging/message_service.py index ff002aa3e9..dad2365b5e 100644 --- a/backend/services/messaging/message_service.py +++ b/backend/services/messaging/message_service.py @@ -1,33 +1,35 @@ +import datetime import re import time -import datetime -import bleach - -from cachetools import TTLCache, cached from typing import List -from flask import current_app -from sqlalchemy import text, func + +import bleach +from cachetools import TTLCache +from databases import Database +from loguru import logger from markdown import markdown +from sqlalchemy import insert -from backend import db, create_app +from backend.config import settings +from backend.db import db_connection from backend.exceptions import NotFound from backend.models.dtos.message_dto import MessageDTO, MessagesDTO from backend.models.dtos.stats_dto import Pagination from backend.models.postgis.message import Message, MessageType from backend.models.postgis.notification import Notification from backend.models.postgis.project import Project, ProjectInfo -from backend.models.postgis.task import TaskStatus, TaskAction, TaskHistory from backend.models.postgis.statuses import TeamRoles +from backend.models.postgis.task import TaskAction, TaskStatus +from backend.models.postgis.utils import timestamp from backend.services.messaging.smtp_service import SMTPService from backend.services.messaging.template_service import ( + clean_html, get_template, get_txt_template, template_var_replacing, - clean_html, ) from backend.services.organisation_service import OrganisationService -from backend.services.users.user_service import UserService, User - +from backend.services.users.user_service import User, UserService message_cache = TTLCache(maxsize=512, ttl=30) @@ -36,21 +38,20 @@ class MessageServiceError(Exception): """Custom Exception to notify callers an error occurred when handling mapping""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class MessageService: @staticmethod - def send_welcome_message(user: User): + async def send_welcome_message(user: User, db: Database): """Sends welcome message to new user at Sign up""" - org_code = current_app.config["ORG_CODE"] + org_code = settings.ORG_CODE text_template = get_txt_template("welcome_message_en.txt") hot_welcome_section = get_txt_template("hot_welcome_section_en.txt") replace_list = [ ["[USERNAME]", user.username], ["[ORG_CODE]", org_code], - ["[ORG_NAME]", current_app.config["ORG_NAME"]], + ["[ORG_NAME]", settings.ORG_NAME], ["[SETTINGS_LINK]", MessageService.get_user_settings_link()], ["[HOT_WELCOME]", hot_welcome_section if org_code == "HOT" else ""], ] @@ -61,22 +62,33 @@ def send_welcome_message(user: User): welcome_message.to_user_id = user.id welcome_message.subject = "Welcome to the {} Tasking Manager".format(org_code) welcome_message.message = text_template - welcome_message.save() - - return welcome_message.id + welcome_message.date = timestamp() + welcome_message.read = False + await Message.save(welcome_message, db) @staticmethod - def send_message_after_validation( - status: int, validated_by: int, mapped_by: int, task_id: int, project_id: int + async def send_message_after_validation( + status: int, + validated_by: int, + mapped_by: int, + task_id: int, + project_id: int, + db: Database, ): """Sends mapper a notification after their task has been marked valid or invalid""" if validated_by == mapped_by: return # No need to send a notification if you've verified your own task - project = Project.get(project_id) - project_name = ProjectInfo.get_dto_for_locale( - project_id, project.default_locale - ).name - user = UserService.get_user_by_id(mapped_by) + project = await Project.get(project_id, db) + project_name_query = """ + SELECT name + FROM project_info + WHERE project_id = :project_id AND locale = :locale + """ + project_name = await db.fetch_val( + project_name_query, + values={"project_id": project_id, "locale": project.default_locale}, + ) + user = await UserService.get_user_by_id(mapped_by, db) text_template = get_txt_template( "invalidation_message_en.txt" if status == TaskStatus.INVALIDATED @@ -91,7 +103,7 @@ def send_message_after_validation( replace_list = [ ["[USERNAME]", user.username], ["[TASK_LINK]", task_link], - ["[ORG_NAME]", current_app.config["ORG_NAME"]], + ["[ORG_NAME]", settings.ORG_NAME], ] text_template = template_var_replacing(text_template, replace_list) @@ -114,101 +126,105 @@ def send_message_after_validation( messages.append( dict(message=validation_message, user=user, project_name=project_name) ) - - # For email alerts - MessageService._push_messages(messages) + await MessageService._push_messages(messages, db) @staticmethod - def send_message_to_all_contributors(project_id: int, message_dto: MessageDTO): + async def send_message_to_all_contributors( + project_id: int, message_dto: MessageDTO + ): """Sends supplied message to all contributors on specified project. Message all contributors can take over a minute to run, so this method is expected to be called on its own thread """ - - app = ( - create_app() - ) # Because message-all run on background thread it needs it's own app context - - with app.app_context(): - contributors = Message.get_all_contributors(project_id) - project = Project.get(project_id) - project_name = ProjectInfo.get_dto_for_locale( - project_id, project.default_locale - ).name + async with db_connection.database.connection() as conn: + contributors = await Message.get_all_contributors(project_id, conn) + project = await Project.get(project_id, conn) + project_info = await ProjectInfo.get_dto_for_locale( + conn, project_id, project.default_locale + ) message_dto.message = "A message from {} managers:

{}".format( MessageService.get_project_link( - project_id, project_name, highlight=True + project_id, project_info.name, highlight=True ), markdown(message_dto.message, output_format="html"), ) - messages = [] for contributor in contributors: - message = Message.from_dto(contributor[0], message_dto) + message = Message.from_dto(contributor, message_dto) message.message_type = MessageType.BROADCAST.value message.project_id = project_id - user = UserService.get_user_by_id(contributor[0]) + user = await UserService.get_user_by_id(contributor, conn) messages.append( - dict(message=message, user=user, project_name=project_name) + dict(message=message, user=user, project_name=project_info.name) ) - - MessageService._push_messages(messages) + await MessageService._push_messages(messages, conn) @staticmethod - def _push_messages(messages): + async def _push_messages(messages: list, db: Database): if len(messages) == 0: return - messages_objs = [] for i, message in enumerate(messages): user = message.get("user") obj = message.get("message") project_name = message.get("project_name") - # Store message in the database only if mentions option are disabled. + + # Skipping message if certain notifications are disabled if ( user.mentions_notifications is False and obj.message_type == MessageType.MENTION_NOTIFICATION.value ): messages_objs.append(obj) continue + if ( user.projects_notifications is False and obj.message_type == MessageType.PROJECT_ACTIVITY_NOTIFICATION.value ): continue + if ( user.projects_notifications is False and obj.message_type == MessageType.BROADCAST.value ): continue + if ( user.teams_announcement_notifications is False and obj.message_type == MessageType.TEAM_BROADCAST.value ): messages_objs.append(obj) continue + if ( user.projects_comments_notifications is False and obj.message_type == MessageType.PROJECT_CHAT_NOTIFICATION.value ): continue + if ( user.tasks_comments_notifications is False and obj.message_type == MessageType.TASK_COMMENT_NOTIFICATION.value ): continue + if user.tasks_notifications is False and obj.message_type in ( MessageType.VALIDATION_NOTIFICATION.value, MessageType.INVALIDATION_NOTIFICATION.value, ): messages_objs.append(obj) continue + # If the notification is enabled, send an email messages_objs.append(obj) - SMTPService.send_email_alert( + await SMTPService.send_email_alert( user.email_address, user.username, user.is_email_verified, message["message"].id, - UserService.get_user_by_id(message["message"].from_user_id).username, + ( + await UserService.get_user_by_id( + message["message"].from_user_id, db + ) + ).username, message["message"].project_id, message["message"].task_id, clean_html(message["message"].subject), @@ -217,30 +233,56 @@ def _push_messages(messages): project_name, ) - if i + 1 % 10 == 0: + if (i + 1) % 10 == 0: time.sleep(0.5) - # Flush messages to the database. - if len(messages_objs) > 0: - db.session.add_all(messages_objs) - db.session.flush() - db.session.commit() + if messages_objs: + insert_values = [ + { + "message": msg.message, + "subject": msg.subject, + "from_user_id": msg.from_user_id, + "to_user_id": msg.to_user_id, + "project_id": msg.project_id, + "task_id": msg.task_id, + "message_type": msg.message_type, + "date": timestamp(), + "read": False, + } + for msg in messages_objs + ] + + # Insert the messages into the database + query = insert(Message).values(insert_values) + await db.execute(query) @staticmethod - def send_message_after_comment( - comment_from: int, comment: str, task_id: int, project_id: int + async def send_message_after_comment( + comment_from: int, comment: str, task_id: int, project_id: int, db: Database ): """Will send a canned message to anyone @'d in a comment""" - comment_from_user = UserService.get_user_by_id(comment_from) - usernames = MessageService._parse_message_for_username( - comment, project_id, task_id + # Fetch the user who made the comment + comment_from_user = await UserService.get_user_by_id(comment_from, db) + # Parse the comment for mentions + usernames = await MessageService._parse_message_for_username( + comment, project_id, task_id, db ) if comment_from_user.username in usernames: usernames.remove(comment_from_user.username) - project = Project.get(project_id) - default_locale = project.default_locale if project else "en" - project_name = ProjectInfo.get_dto_for_locale(project_id, default_locale).name - if len(usernames) != 0: + + # Fetch project details + project = await db.fetch_one( + "SELECT * FROM projects WHERE id = :project_id", {"project_id": project_id} + ) + default_locale = project["default_locale"] if project else "en" + + # Get the project info DTO using the get_dto_for_locale function + project_info_dto = await ProjectInfo.get_dto_for_locale( + db, project_id, default_locale + ) + project_name = project_info_dto.name # Use the `name` field from the DTO + + if usernames: task_link = MessageService.get_task_link(project_id, task_id) project_link = MessageService.get_project_link(project_id, project_name) @@ -264,107 +306,115 @@ def send_message_after_comment( "strong", "ul", ] - allowed_atrributes = {"a": ["href", "rel"], "img": ["src", "alt"]} + allowed_attributes = {"a": ["href", "rel"], "img": ["src", "alt"]} + + # Convert comment to HTML using markdown and sanitize it with bleach clean_comment = bleach.clean( markdown(comment, output_format="html"), tags=allowed_tags, - attributes=allowed_atrributes, - ) # Bleach input to ensure no nefarious script tags etc - clean_comment = bleach.linkify(clean_comment) + attributes=allowed_attributes, + ) + clean_comment = bleach.linkify(clean_comment) # Linkify URLs in the comment messages = [] for username in usernames: try: - user = UserService.get_user_by_username(username) + user = await UserService.get_user_by_username(username, db) except NotFound: - continue # If we can't find the user, keep going no need to fail + continue message = Message() message.message_type = MessageType.MENTION_NOTIFICATION.value message.project_id = project_id message.task_id = task_id message.from_user_id = comment_from - message.to_user_id = user.id - message.subject = ( - f"You were mentioned in a comment in {task_link} " - + f"of Project {project_link}" - ) + message.to_user_id = user["id"] + message.subject = f"You were mentioned in a comment in {task_link} of Project {project_link}" message.message = clean_comment + message.date = timestamp() + message.read = False messages.append( dict(message=message, user=user, project_name=project_name) ) - MessageService._push_messages(messages) - - # Notify all contributors except the user that created the comment. - results = ( - TaskHistory.query.with_entities(TaskHistory.user_id.distinct()) - .filter(TaskHistory.project_id == project_id) - .filter(TaskHistory.task_id == task_id) - .filter(TaskHistory.user_id != comment_from) - .filter(TaskHistory.action == TaskAction.STATE_CHANGE.name) - .all() + await MessageService._push_messages(messages, db) + + # Notify all contributors except the comment author + results = await db.fetch_all( + """ + SELECT DISTINCT user_id + FROM task_history + WHERE project_id = :project_id + AND task_id = :task_id + AND user_id != :comment_from + AND action = 'STATE_CHANGE' + """, + { + "project_id": project_id, + "task_id": task_id, + "comment_from": comment_from, + }, ) - contributed_users = [r[0] for r in results] - if len(contributed_users) != 0: - user_from = User.query.get(comment_from) - if user_from is None: - raise ValueError("Username not found") - user_link = MessageService.get_user_link(user_from.username) + contributed_users = [r["user_id"] for r in results] + if contributed_users: + user_from = await UserService.get_user_by_id(comment_from, db) + user_link = MessageService.get_user_link(user_from.username) task_link = MessageService.get_task_link(project_id, task_id) project_link = MessageService.get_project_link(project_id, project_name) messages = [] for user_id in contributed_users: try: - user = UserService.get_user_by_id(user_id) - # if user was mentioned, a message has already been sent to them, - # so we can skip + user = await UserService.get_user_by_id(user_id, db) if user.username in usernames: break except NotFound: - continue # If we can't find the user, keep going no need to fail + continue message = Message() message.message_type = MessageType.TASK_COMMENT_NOTIFICATION.value message.project_id = project_id - message.from_user_id = comment_from message.task_id = task_id + message.from_user_id = comment_from message.to_user_id = user.id message.subject = f"{user_link} left a comment in {task_link} of Project {project_link}" message.message = comment + message.date = timestamp() + message.read = False messages.append( dict(message=message, user=user, project_name=project_name) ) - - MessageService._push_messages(messages) + await MessageService._push_messages(messages, db) @staticmethod - def send_project_transfer_message( + async def send_project_transfer_message( project_id: int, transferred_to: str, transferred_by: str, ): """Will send a message to the manager of the organization after a project is transferred""" - app = ( - create_app() - ) # Because message-all run on background thread it needs it's own app context - - with app.app_context(): - project = Project.get(project_id) - project_name = project.get_project_title(project.default_locale) - + async with db_connection.database.connection() as db: + project = await Project.get(project_id, db) + project_name = await project.get_project_title( + db, project.id, project.default_locale + ) + from_user = await User.get_by_username(transferred_by, db) + organisation = await OrganisationService.get_organisation_by_id_as_dto( + project.organisation_id, from_user.id, False, db + ) message = Message() message.message_type = MessageType.SYSTEM.value + message.date = timestamp() + message.read = False message.subject = f"Project {project_name} #{project_id} was transferred to {transferred_to}" message.message = ( f"Project {project_name} #{project_id} associated with your" - + f"organisation {project.organisation.name} was transferred to {transferred_to} by {transferred_by}." + + f"organisation {organisation.name} was transferred to {transferred_to} by {transferred_by}." ) values = { - "PROJECT_ORG_NAME": project.organisation.name, + "PROJECT_ORG_NAME": organisation.name, "PROJECT_ORG_ID": project.organisation_id, "PROJECT_NAME": project_name, "PROJECT_ID": project_id, @@ -372,16 +422,13 @@ def send_project_transfer_message( "TRANSFERRED_BY": transferred_by, } html_template = get_template("project_transfer_alert_en.html", values) - - managers = OrganisationService.get_organisation_by_id_as_dto( - project.organisation_id, User.get_by_username(transferred_by).id, False - ).managers + managers = organisation.managers for manager in managers: - manager = UserService.get_user_by_username(manager.username) + manager = await UserService.get_user_by_username(manager.username, db) message.to_user_id = manager.id - message.save() + await message.save(db) if manager.email_address and manager.is_email_verified: - SMTPService._send_message( + await SMTPService._send_message( manager.email_address, message.subject, html_template, @@ -390,20 +437,25 @@ def send_project_transfer_message( @staticmethod def get_user_link(username: str): - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL return f'{username}' @staticmethod def get_team_link(team_name: str, team_id: int, management: bool): - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL if management is True: return f'{team_name}' else: return f'{team_name}' @staticmethod - def send_request_to_join_team( - from_user: int, from_username: str, to_user: int, team_name: str, team_id: int + async def send_request_to_join_team( + from_user: int, + from_username: str, + to_user: int, + team_name: str, + team_id: int, + db: Database, ): message = Message() message.message_type = MessageType.REQUEST_TEAM_NOTIFICATION.value @@ -414,34 +466,36 @@ def send_request_to_join_team( message.subject = f"{user_link} requested to join {team_link}" message.message = f"{user_link} has requested to join the {team_link} team.\ Access the team management page to accept or reject that request." - MessageService._push_messages( - [dict(message=message, user=db.session.get(User, to_user))] - ) + user = await UserService.get_user_by_id(to_user, db) + await MessageService._push_messages([dict(message=message, user=user)], db) @staticmethod - def accept_reject_request_to_join_team( + async def accept_reject_request_to_join_team( from_user: int, from_username: str, to_user: int, team_name: str, team_id: int, response: str, + db: Database, ): message = Message() message.message_type = MessageType.REQUEST_TEAM_NOTIFICATION.value message.from_user_id = from_user message.to_user_id = to_user + message.date = timestamp() + message.read = False team_link = MessageService.get_team_link(team_name, team_id, False) user_link = MessageService.get_user_link(from_username) message.subject = f"Your request to join team {team_link} has been {response}ed" message.message = ( f"{user_link} has {response}ed your request to join the {team_link} team." ) - message.add_message() - message.save() + user = await UserService.get_user_by_id(to_user, db) + await MessageService._push_messages([dict(message=message, user=user)], db) @staticmethod - def accept_reject_invitation_request_for_team( + async def accept_reject_invitation_request_for_team( from_user: int, from_username: str, to_user: int, @@ -449,11 +503,14 @@ def accept_reject_invitation_request_for_team( team_name: str, team_id: int, response: str, + db: Database, ): message = Message() message.message_type = MessageType.INVITATION_NOTIFICATION.value message.from_user_id = from_user message.to_user_id = to_user + message.date = timestamp() + message.read = False message.subject = "{} {}ed to join {}".format( MessageService.get_user_link(from_username), response, @@ -465,17 +522,18 @@ def accept_reject_invitation_request_for_team( sending_member, MessageService.get_team_link(team_name, team_id, True), ) - message.add_message() - message.save() + user = await UserService.get_user_by_id(to_user, db) + await MessageService._push_messages([dict(message=message, user=user)], db) @staticmethod - def send_team_join_notification( + async def send_team_join_notification( from_user: int, from_username: str, to_user: int, team_name: str, team_id: int, role: str, + db: Database, ): message = Message() message.message_type = MessageType.INVITATION_NOTIFICATION.value @@ -486,35 +544,33 @@ def send_team_join_notification( message.subject = f"You have been added to team {team_link}" message.message = f"You have been added to the team {team_link} as {role} by {user_link}.\ Access the {team_link}'s page to view more info about this team." - - message.add_message() - message.save() + message.date = timestamp() + message.read = False + user = await UserService.get_user_by_id(to_user, db) + await MessageService._push_messages([dict(message=message, user=user)], db) @staticmethod - def send_message_after_chat( - chat_from: int, chat: str, project_id: int, project_name: str + async def send_message_after_chat( + chat_from: int, + chat: str, + project_id: int, + project_name: str, ): - """Send alert to user if they were @'d in a chat message""" - app = ( - create_app() - ) # Because message-all run on background thread it needs it's own app context - if ( - app.config["ENVIRONMENT"] == "test" - ): # Don't send in test mode as this will cause tests to fail. - return - with app.app_context(): - usernames = MessageService._parse_message_for_username(chat, project_id) + async with db_connection.database.connection() as db: + usernames = await MessageService._parse_message_for_username( + message=chat, project_id=project_id, db=db + ) if len(usernames) != 0: link = MessageService.get_project_link( project_id, project_name, include_chat_section=True ) messages = [] for username in usernames: - current_app.logger.debug(f"Searching for {username}") + logger.debug(f"Searching for {username}") try: - user = UserService.get_user_by_username(username) + user = await UserService.get_user_by_username(username, db) except NotFound: - current_app.logger.error(f"Username {username} not found") + logger.error(f"Username {username} not found") continue # If we can't find the user, keep going no need to fail message = Message() @@ -522,33 +578,45 @@ def send_message_after_chat( message.project_id = project_id message.from_user_id = chat_from message.to_user_id = user.id + message.date = timestamp() + message.read = False message.subject = f"You were mentioned in Project {link} chat" message.message = chat messages.append( dict(message=message, user=user, project_name=project_name) ) - MessageService._push_messages(messages) - - query = f""" select user_id from project_favorites where project_id ={project_id}""" - with db.engine.connect() as conn: - favorited_users_results = conn.execute(text(query)) - favorited_users = [r[0] for r in favorited_users_results] - - # Notify all contributors except the user that created the comment. - contributed_users_results = ( - TaskHistory.query.with_entities(TaskHistory.user_id.distinct()) - .filter(TaskHistory.project_id == project_id) - .filter(TaskHistory.user_id != chat_from) - .filter(TaskHistory.action == TaskAction.STATE_CHANGE.name) - .all() + await MessageService._push_messages(messages, db) + favorited_users_query = """ select user_id from project_favorites where project_id = :project_id""" + favorited_users_values = { + "project_id": project_id, + } + favorited_users_results = await db.fetch_all( + query=favorited_users_query, values=favorited_users_values ) - contributed_users = [r[0] for r in contributed_users_results] + favorited_users = [r.user_id for r in favorited_users_results] + # Notify all contributors except the user that created the comment. + contributed_users_query = """ + SELECT DISTINCT user_id + FROM task_history + WHERE project_id = :project_id + AND user_id != :chat_from + AND action = :state_change_action + """ + values = { + "project_id": project_id, + "chat_from": chat_from, + "state_change_action": TaskAction.STATE_CHANGE.name, + } + contributed_users_results = await db.fetch_all( + query=contributed_users_query, values=values + ) + contributed_users = [r.user_id for r in contributed_users_results] users_to_notify = list(set(contributed_users + favorited_users)) if len(users_to_notify) != 0: - from_user = User.query.get(chat_from) + from_user = await UserService.get_user_by_id(chat_from, db) from_user_link = MessageService.get_user_link(from_user.username) project_link = MessageService.get_project_link( project_id, project_name, include_chat_section=True @@ -556,14 +624,16 @@ def send_message_after_chat( messages = [] for user_id in users_to_notify: try: - user = UserService.get_user_by_id(user_id) + user = await UserService.get_user_by_id(user_id, db) except NotFound: - continue # If we can't find the user, keep going no need to fail + continue message = Message() message.message_type = MessageType.PROJECT_CHAT_NOTIFICATION.value message.project_id = project_id message.from_user_id = chat_from message.to_user_id = user.id + message.date = timestamp() + message.read = False message.subject = ( f"{from_user_link} left a comment in project {project_link}" ) @@ -572,137 +642,184 @@ def send_message_after_chat( dict(message=message, user=user, project_name=project_name) ) - # it's important to keep that line inside the if to avoid duplicated emails - MessageService._push_messages(messages) + await MessageService._push_messages(messages, db) - @staticmethod - def send_favorite_project_activities(user_id: int): - current_app.logger.debug("Sending Favorite Project Activities") - favorited_projects = UserService.get_projects_favorited(user_id) - contributed_projects = UserService.get_projects_mapped(user_id) - if contributed_projects is None: - contributed_projects = [] - - for favorited_project in favorited_projects.favorited_projects: - contributed_projects.append(favorited_project.project_id) - - recently_updated_projects = ( - Project.query.with_entities( - Project.id, func.DATE(Project.last_updated).label("last_updated") - ) - .filter(Project.id.in_(contributed_projects)) - .filter( - func.DATE(Project.last_updated) - > datetime.date.today() - datetime.timedelta(days=300) - ) + async def send_favorite_project_activities(user_id: int, db: Database): + logger.debug("Sending Favorite Project Activities") + + # Fetch favorited and contributed projects + favorited_projects = await UserService.get_projects_favorited(user_id, db) + contributed_projects = await UserService.get_projects_mapped(user_id, db) or [] + + contributed_projects.extend( + [fp.project_id for fp in favorited_projects.favorited_projects] + ) + + # Fetch recently updated projects + recently_updated_query = """ + SELECT id, DATE(last_updated) as last_updated + FROM projects + WHERE id = ANY(:contributed_projects) + AND DATE(last_updated) > :date_threshold + """ + + recently_updated_projects = await db.fetch_all( + recently_updated_query, + values={ + "contributed_projects": contributed_projects, + "date_threshold": datetime.utcnow().date() + - datetime.timedelta(days=300), + }, ) - user = UserService.get_user_by_id(user_id) + + user = await UserService.get_user_by_id(user_id, db) messages = [] + for project in recently_updated_projects: activity_message = [] - query_last_active_users = """ select distinct(user_id) from - (select user_id from task_history where project_id = :project_id - order by action_date desc limit 15 ) t """ - project_name = ProjectInfo.get_dto_for_locale( - project.id, project.default_locale - ).name - with db.engine.connect() as conn: - last_active_users = conn.execute( - text(query_last_active_users), project_id=project.id - ) - for recent_user_id in last_active_users: - recent_user_details = UserService.get_user_by_id(recent_user_id) + # Fetch last active users + query_last_active_users = """ + SELECT DISTINCT(user_id) FROM ( + SELECT user_id FROM task_history + WHERE project_id = :project_id + ORDER BY action_date DESC + LIMIT 15 + ) t + """ + + last_active_users = await db.fetch_all( + query_last_active_users, values={"project_id": project["id"]} + ) + + for recent_user in last_active_users: + recent_user_details = await UserService.get_user_by_id( + recent_user["user_id"], db + ) user_profile_link = MessageService.get_user_profile_link( recent_user_details.username ) activity_message.append(user_profile_link) - activity_message = str(activity_message)[1:-1] - project_link = MessageService.get_project_link(project.id, project_name) - message = Message() - message.message_type = MessageType.PROJECT_ACTIVITY_NOTIFICATION.value - message.project_id = project.id - message.to_user_id = user.id - message.subject = ( - "Recent activities from your contributed/favorited Projects" - ) - message.message = ( - f"{activity_message} contributed to {project_link} recently" - ) - messages.append(dict(message=message, user=user, project_name=project_name)) + activity_message = ", ".join(activity_message) + project_name = await ProjectInfo.get_project_name(project["id"], db) + project_link = MessageService.get_project_link(project["id"], project_name) + + message = { + "message_type": MessageType.PROJECT_ACTIVITY_NOTIFICATION.value, + "project_id": project["id"], + "to_user_id": user.id, + "date": datetime.utcnow(), + "read": False, + "subject": "Recent activities from your contributed/favorited Projects", + "message": f"{activity_message} contributed to {project_link} recently", + } + messages.append(message) - MessageService._push_messages(messages) + await MessageService._push_messages(messages, db) @staticmethod - def resend_email_validation(user_id: int): + async def resend_email_validation(user_id: int, db: Database): """Resends the email validation email to the logged in user""" - user = UserService.get_user_by_id(user_id) + user = await UserService.get_user_by_id(user_id, db) if user.email_address is None: raise ValueError("EmailNotSet- User does not have an email address") - SMTPService.send_verification_email(user.email_address, user.username) + await SMTPService.send_verification_email(user.email_address, user.username) @staticmethod - def _parse_message_for_bulk_mentions( - message: str, project_id: int, task_id: int = None + async def _parse_message_for_bulk_mentions( + message: str, project_id: int, task_id: int = None, db: Database = None ) -> List[str]: parser = re.compile(r"((?<=#)\w+|\[.+?\])") parsed = parser.findall(message) usernames = [] - project = db.session.get(Project, project_id) + query = """ + SELECT * FROM projects + WHERE id = :project_id + """ + project = await db.fetch_one(query, values={"project_id": project_id}) + + # Fetch project details, including author username by joining users and projects + project_query = """ + SELECT p.*, u.username AS author_username + FROM projects p + JOIN users u ON p.author_id = u.id + WHERE p.id = :project_id + """ + project = await db.fetch_one(project_query, {"project_id": project_id}) - if project is None: + if not project: return usernames - if "author" in parsed or "managers" in parsed: - usernames.append(project.author.username) - if "managers" in parsed: - teams = [ - t - for t in project.teams - if t.role == TeamRoles.PROJECT_MANAGER.value - ] - team_members = [ - [u.member.username for u in t.team.members if u.active is True] - for t in teams - ] - - team_members = [item for sublist in team_members for item in sublist] - usernames.extend(team_members) + # Add author if mentioned + if "author" in parsed: + usernames.append(project["author_username"]) + + # Add project managers if mentioned + if "managers" in parsed: + team_manager_role = TeamRoles.PROJECT_MANAGER.value + + team_members = await db.fetch_all( + """ + SELECT DISTINCT u.username + FROM users u + JOIN team_members tm ON u.id = tm.user_id + JOIN project_teams pt ON tm.team_id = pt.team_id + WHERE pt.role = :team_manager_role + AND pt.project_id = :project_id + AND tm.active = TRUE + """, + {"project_id": project_id, "team_manager_role": team_manager_role}, + ) + usernames.extend([member["username"] for member in team_members]) + + organisation_managers_query = """ + SELECT DISTINCT u.username + FROM projects p + JOIN organisation_managers om ON p.organisation_id = om.organisation_id + JOIN users u ON u.id = om.user_id + WHERE p.id = :project_id + """ + + organisation_managers = await db.fetch_all( + organisation_managers_query, values={"project_id": project_id} + ) + if organisation_managers: + usernames.extend( + [manager["username"] for manager in organisation_managers] + ) + + # Add contributors if task_id is provided and contributors are mentioned if task_id and "contributors" in parsed: - contributors = Message.get_all_tasks_contributors(project_id, task_id) + contributors = await Message.get_all_tasks_contributors( + project_id, task_id, db + ) usernames.extend(contributors) - return usernames + return list(set(usernames)) @staticmethod - def _parse_message_for_username( - message: str, project_id: int, task_id: int = None + async def _parse_message_for_username( + message: str, project_id: int, task_id: int = None, db: Database = None ) -> List[str]: - """Extracts all usernames from a comment looks for format @[user name]""" - + """Extracts all usernames from a comment looking for format @[user name]""" parser = re.compile(r"((?<=@)\w+|\[.+?\])") - - usernames = [] - for username in parser.findall(message): - username = username.replace("[", "", 1) - index = username.rfind("]") - username = username.replace("]", "", index) - usernames.append(username) - + usernames = [ + username.replace("[", "", 1).replace("]", "", username.rfind("]")) + for username in parser.findall(message) + ] usernames.extend( - MessageService._parse_message_for_bulk_mentions( - message, project_id, task_id + await MessageService._parse_message_for_bulk_mentions( + message, project_id, task_id, db ) ) - usernames = list(set(usernames)) - return usernames + return list(set(usernames)) + # @cached(message_cache) @staticmethod - @cached(message_cache) - def has_user_new_messages(user_id: int) -> dict: + async def has_user_new_messages(user_id: int, db: Database) -> dict: """Determines if the user has any unread messages""" - count = Notification.get_unread_message_count(user_id) + count = await Notification.get_unread_message_count(user_id, db) new_messages = False if count > 0: @@ -711,7 +828,8 @@ def has_user_new_messages(user_id: int) -> dict: return dict(newMessages=new_messages, unread=count) @staticmethod - def get_all_messages( + async def get_all_messages( + db: Database, user_id: int, locale: str, page: int, @@ -725,124 +843,236 @@ def get_all_messages( status=None, ): """Get all messages for user""" - sort_column = Message.__table__.columns.get(sort_by) - if sort_column is None: - sort_column = Message.date + sort_column = ( - sort_column.asc() if sort_direction.lower() == "asc" else sort_column.desc() + sort_by + if sort_by in ["date", "message_type", "from_user_id", "project_id", "read"] + else "date" ) - query = Message.query + sort_direction = ( + "ASC" if sort_direction and sort_direction.lower() == "asc" else "DESC" + ) + + query = """ + SELECT + m.id AS message_id, + m.subject, + m.message, + m.from_user_id, + m.to_user_id, + m.task_id, + m.message_type, + m.date AS sent_date, + m.read, + m.project_id, + u.username AS from_username, + u.picture_url AS display_picture_url + FROM + messages m + LEFT JOIN + users u ON m.from_user_id = u.id + WHERE + m.to_user_id = :user_id + """ + + filters = [] + params = {"user_id": user_id} - if project is not None: - query = query.filter(Message.project_id == project) + if project: + filters.append("m.project_id = :project") + params["project"] = int(project) - if task_id is not None: - query = query.filter(Message.task_id == task_id) + if task_id: + filters.append("m.task_id = :task_id") + params["task_id"] = int(task_id) if status in ["read", "unread"]: - query = query.filter(Message.read == (True if status == "read" else False)) + filters.append("m.read = :read_status") + params["read_status"] = True if status == "read" else False if message_type: - message_type_filters = map(int, message_type.split(",")) - query = query.filter(Message.message_type.in_(message_type_filters)) + filters.append("m.message_type = ANY(:message_types)") + params["message_types"] = list(map(int, message_type.split(","))) - if from_username is not None: - query = query.join(Message.from_user).filter( - User.username.ilike(from_username + "%") - ) + if from_username: + filters.append("u.username ILIKE :from_username") + params["from_username"] = from_username + "%" - results = ( - query.filter(Message.to_user_id == user_id) - .order_by(sort_column) - .paginate(page=page, per_page=page_size, error_out=True) - ) - # if results.total == 0: - # raise NotFound() + if filters: + query += " AND " + " AND ".join(filters) + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT :limit OFFSET :offset" + params["limit"] = int(page_size) + params["offset"] = (int(page) - 1) * int(page_size) + + messages = await db.fetch_all(query, params) messages_dto = MessagesDTO() - for item in results.items: - if isinstance(item, tuple): - message_dto = item[0].as_dto() - message_dto.project_title = item[1].name - else: - message_dto = item.as_dto() - if item.project_id is not None: - message_dto.project_title = item.project.get_project_title(locale) - - messages_dto.user_messages.append(message_dto) - - messages_dto.pagination = Pagination(results) + for msg in messages: + message_dict = dict(msg) + if message_dict["message_type"]: + message_dict["message_type"] = MessageType( + message_dict["message_type"] + ).name + if message_dict["project_id"]: + try: + message_dict["project_title"] = ( + await Project.get_project_title( + db, message_dict["project_id"], locale + ) + or "" + ) + except Exception: + raise MessageServiceError("Unable to fetch project name.") + msg_dto = MessageDTO(**message_dict).copy(exclude={"from_user_id"}) + messages_dto.user_messages.append(msg_dto) + + total_count_query = """ + SELECT COUNT(*) AS total_count + FROM messages m + WHERE m.to_user_id = :user_id + """ + if filters: + total_count_query += " AND " + " AND ".join(filters) + + total_count_params = {"user_id": params["user_id"]} + if "project" in params: + total_count_params["project"] = params["project"] + if "task_id" in params: + total_count_params["task_id"] = params["task_id"] + if "read_status" in params: + total_count_params["read_status"] = params["read_status"] + if "message_types" in params: + total_count_params["message_types"] = params["message_types"] + if "from_username" in params: + total_count_params["from_username"] = params["from_username"] + + total_count = await db.fetch_one(total_count_query, total_count_params) + + messages_dto.pagination = Pagination.from_total_count( + page=int(page), per_page=int(page_size), total=total_count["total_count"] + ) return messages_dto @staticmethod - def get_message(message_id: int, user_id: int) -> Message: - """Gets the specified message""" - message = db.session.get(Message, message_id) + async def get_message(message_id: int, user_id: int, db: Database): + """Gets the specified message.""" + query = """ + SELECT * FROM messages WHERE id = :message_id + """ + message = await db.fetch_one(query, values={"message_id": message_id}) if message is None: raise NotFound(sub_code="MESSAGE_NOT_FOUND", message_id=message_id) - if message.to_user_id != int(user_id): + if message["to_user_id"] != user_id: raise MessageServiceError( - "AccessOtherUserMessage- " - + f"User {user_id} attempting to access another users message {message_id}" + f"AccessOtherUserMessage - User {user_id} attempting to access another user's message {message_id}" ) return message @staticmethod - def mark_all_messages_read(user_id: int, message_type: str = None): + async def mark_all_messages_read( + user_id: int, db: Database, message_type: str = None + ): """Marks all messages as read for the user ----------------------------------------- :param user_id: The user id + :param db: Database connection :param message_type: The message types to mark as read returns: None """ if message_type is not None: - # Wrap in list for unit tests to work message_type = list(map(int, message_type.split(","))) - Message.mark_all_messages_read(user_id, message_type) + await Message.mark_all_messages_read(user_id, db, message_type) @staticmethod - def mark_multiple_messages_read(message_ids: list, user_id: int): + async def mark_multiple_messages_read( + message_ids: list, user_id: int, db: Database + ): """Marks the specified messages as read for the user --------------------------------------------------- :param message_ids: List of message ids to mark as read :param user_id: The user id + :param db: Database connection returns: None """ - Message.mark_multiple_messages_read(message_ids, user_id) + await Message.mark_multiple_messages_read(message_ids, user_id, db) @staticmethod - def get_message_as_dto(message_id: int, user_id: int): + async def get_message_as_dto(message_id: int, user_id: int, db: Database): """Gets the selected message and marks it as read""" - message = MessageService.get_message(message_id, user_id) - message.mark_as_read() - return message.as_dto() + query = """ + SELECT + m.id AS message_id, + m.subject, + m.message, + m.to_user_id, + m.from_user_id, + m.task_id, + m.message_type, + m.date AS sent_date, + m.read, + m.project_id, + u.username AS from_username, + u.picture_url AS display_picture_url, + pi.name AS project_title + FROM + messages m + LEFT JOIN + users u ON m.from_user_id = u.id + LEFT JOIN + project_info pi ON m.project_id = pi.project_id + WHERE + m.id = :message_id + """ + message = await db.fetch_one(query, {"message_id": message_id}) + + if message is None: + raise NotFound(sub_code="MESSAGE_NOT_FOUND", message_id=message_id) + + if message["to_user_id"] != user_id: + raise MessageServiceError( + "AccessOtherUserMessage- " + + f"User {user_id} attempting to access another user's message {message_id}" + ) + + update_query = """ + UPDATE messages SET read = TRUE WHERE id = :message_id + """ + await db.execute(update_query, {"message_id": message_id}) + + message_dict = dict(message) + message_dict["message_type"] = MessageType(message_dict["message_type"]).name + return message_dict @staticmethod - def delete_message(message_id: int, user_id: int): + async def delete_message(message_id: int, user_id: int, db: Database): """Deletes the specified message""" - message = MessageService.get_message(message_id, user_id) - message.delete() + delete_query = """ + DELETE FROM messages WHERE id = :message_id AND to_user_id = :user_id + """ + await db.execute(delete_query, {"message_id": message_id, "user_id": user_id}) @staticmethod - def delete_multiple_messages(message_ids: list, user_id: int): + async def delete_multiple_messages(message_ids: list, user_id: int, db: Database): """Deletes the specified messages to the user""" - Message.delete_multiple_messages(message_ids, user_id) + await Message.delete_multiple_messages(message_ids, user_id, db) @staticmethod - def delete_all_messages(user_id: int, message_type: str = None): + async def delete_all_messages(user_id: int, db: Database, message_type: str = None): """Deletes all messages to the user ---------------------------------- :param user_id: The user id + :param db: Database connection :param message_type: The message types to delete (comma separated) returns: None """ if message_type is not None: # Wrap in list for unit tests to work message_type = list(map(int, message_type.split(","))) - Message.delete_all_messages(user_id, message_type) + await Message.delete_all_messages(user_id, db, message_type) @staticmethod def get_task_link( @@ -850,7 +1080,7 @@ def get_task_link( ) -> str: """Helper method that generates a link to the task""" if not base_url: - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL style = "" if highlight: style = "color: #d73f3f" @@ -866,7 +1096,7 @@ def get_project_link( ) -> str: """Helper method to generate a link to project chat""" if not base_url: - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL if include_chat_section: section = "#questionsAndComments" else: @@ -881,7 +1111,7 @@ def get_project_link( def get_user_profile_link(user_name: str, base_url=None) -> str: """Helper method to generate a link to a user profile""" if not base_url: - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL return f'{user_name}' @@ -889,7 +1119,7 @@ def get_user_profile_link(user_name: str, base_url=None) -> str: def get_user_settings_link(section=None, base_url=None) -> str: """Helper method to generate a link to a user profile""" if not base_url: - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL return f'User Settings' @@ -899,6 +1129,6 @@ def get_organisation_link( ) -> str: """Helper method to generate a link to a user profile""" if not base_url: - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL return f'{organisation_name}' diff --git a/backend/services/messaging/smtp_service.py b/backend/services/messaging/smtp_service.py index 610e1fab18..7bb2e141ae 100644 --- a/backend/services/messaging/smtp_service.py +++ b/backend/services/messaging/smtp_service.py @@ -1,20 +1,24 @@ import urllib.parse + +from databases import Database +from fastapi_mail import MessageSchema, MessageType from itsdangerous import URLSafeTimedSerializer -from flask import current_app -from flask_mail import Message +from loguru import logger -from backend import mail, create_app +# from backend import mail, create_app +from backend import mail +from backend.config import settings from backend.models.postgis.message import Message as PostgisMessage from backend.models.postgis.statuses import EncouragingEmailType from backend.services.messaging.template_service import ( - get_template, format_username_link, + get_template, ) class SMTPService: @staticmethod - def send_verification_email(to_address: str, username: str): + async def send_verification_email(to_address: str, username: str): """Sends a verification email with a unique token so we can verify user owns this email address""" # TODO these could be localised if needed, in the future verification_url = SMTPService._generate_email_verification_url( @@ -25,13 +29,12 @@ def send_verification_email(to_address: str, username: str): "VERIFICATION_LINK": verification_url, } html_template = get_template("email_verification_en.html", values) - subject = "Confirm your email address" - SMTPService._send_message(to_address, subject, html_template) + await SMTPService._send_message(to_address, subject, html_template) return True @staticmethod - def send_welcome_email(to_address: str, username: str): + async def send_welcome_email(to_address: str, username: str): """Sends email welcoming new user to tasking manager""" values = { "USERNAME": username, @@ -39,12 +42,12 @@ def send_welcome_email(to_address: str, username: str): html_template = get_template("welcome.html", values) subject = "Welcome to Tasking Manager" - SMTPService._send_message(to_address, subject, html_template) + await SMTPService._send_message(to_address, subject, html_template) return True @staticmethod - def send_contact_admin_email(data): - email_to = current_app.config["EMAIL_CONTACT_ADDRESS"] + async def send_contact_admin_email(data): + email_to = settings.EMAIL_CONTACT_ADDRESS if email_to is None: raise ValueError( "This feature is not implemented due to missing variable TM_EMAIL_CONTACT_ADDRESS." @@ -59,72 +62,69 @@ def send_contact_admin_email(data): ) subject = "New contact from {name}".format(name=data.get("name")) - SMTPService._send_message(email_to, subject, message, message) + await SMTPService._send_message(email_to, subject, message, message) @staticmethod - def send_email_to_contributors_on_project_progress( + async def send_email_to_contributors_on_project_progress( email_type: str, project_id: int = None, project_name: str = None, project_completion: int = None, + db: Database = None, ): """Sends an encouraging email to a users when a project they have contributed to make progress""" from backend.services.users.user_service import UserService - app = ( - create_app() - ) # Because message-all run on background thread it needs it's own app context - with app.app_context(): - if email_type == EncouragingEmailType.PROJECT_PROGRESS.value: - subject = "The project you have contributed to has made progress." - elif email_type == EncouragingEmailType.PROJECT_COMPLETE.value: - subject = "The project you have contributed to has been completed." - values = { - "EMAIL_TYPE": email_type, - "PROJECT_ID": project_id, - "PROJECT_NAME": project_name, - "PROJECT_COMPLETION": project_completion, - } - contributor_ids = PostgisMessage.get_all_contributors(project_id) - for contributor_id in contributor_ids: - contributor = UserService.get_user_by_id(contributor_id[0]) - values["USERNAME"] = contributor.username - if email_type == EncouragingEmailType.BEEN_SOME_TIME.value: - recommended_projects = UserService.get_recommended_projects( - contributor.username, "en" - ).results - projects = [] - for recommended_project in recommended_projects[:4]: - projects.append( - { - "org_logo": recommended_project.organisation_logo, - "priority": recommended_project.priority, - "name": recommended_project.name, - "id": recommended_project.project_id, - "description": recommended_project.short_description, - "total_contributors": recommended_project.total_contributors, - "difficulty": recommended_project.difficulty, - "progress": recommended_project.percent_mapped, - "due_date": recommended_project.due_date, - } - ) - - values["PROJECTS"] = projects - html_template = get_template("encourage_mapper_en.html", values) - if ( - contributor.email_address - and contributor.is_email_verified - and contributor.projects_notifications - ): - current_app.logger.debug( - f"Sending {email_type} email to {contributor.email_address} for project {project_id}" - ) - SMTPService._send_message( - contributor.email_address, subject, html_template + if email_type == EncouragingEmailType.PROJECT_PROGRESS.value: + subject = "The project you have contributed to has made progress." + elif email_type == EncouragingEmailType.PROJECT_COMPLETE.value: + subject = "The project you have contributed to has been completed." + values = { + "EMAIL_TYPE": email_type, + "PROJECT_ID": project_id, + "PROJECT_NAME": project_name, + "PROJECT_COMPLETION": project_completion, + } + contributor_ids = await PostgisMessage.get_all_contributors(project_id, db) + for contributor_id in contributor_ids: + contributor = await UserService.get_user_by_id(contributor_id, db) + values["USERNAME"] = contributor.username + if email_type == EncouragingEmailType.BEEN_SOME_TIME.value: + recommended_projects = await UserService.get_recommended_projects( + contributor.username, "en", db + ).results + projects = [] + for recommended_project in recommended_projects[:4]: + projects.append( + { + "org_logo": recommended_project.organisation_logo, + "priority": recommended_project.priority, + "name": recommended_project.name, + "id": recommended_project.project_id, + "description": recommended_project.short_description, + "total_contributors": recommended_project.total_contributors, + "difficulty": recommended_project.difficulty, + "progress": recommended_project.percent_mapped, + "due_date": recommended_project.due_date, + } ) + values["PROJECTS"] = projects + html_template = get_template("encourage_mapper_en.html", values) + if ( + contributor.email_address + and contributor.is_email_verified + and contributor.projects_notifications + ): + logger.debug( + f"Sending {email_type} email to {contributor.email_address} for project {project_id}" + ) + await SMTPService._send_message( + contributor.email_address, subject, html_template + ) + @staticmethod - def send_email_alert( + async def send_email_alert( to_address: str, username: str, user_email_verified: bool, @@ -142,13 +142,13 @@ def send_email_alert( if not user_email_verified: return False - current_app.logger.debug(f"Test if email required {to_address}") - from_user_link = f"{current_app.config['APP_BASE_URL']}/users/{from_username}" - project_link = f"{current_app.config['APP_BASE_URL']}/projects/{project_id}" - task_link = f"{current_app.config['APP_BASE_URL']}/projects/{project_id}/tasks/?search={task_id}" - settings_url = "{}/settings#notifications".format( - current_app.config["APP_BASE_URL"] + logger.debug(f"Test if email required {to_address}") + from_user_link = f"{settings.APP_BASE_URL}/users/{from_username}" + project_link = f"{settings.APP_BASE_URL}/projects/{project_id}" + task_link = ( + f"{settings.APP_BASE_URL}/projects/{project_id}/tasks/?search={task_id}" ) + settings_url = "{}/settings#notifications".format(settings.APP_BASE_URL) if not to_address: return False # Many users will not have supplied email address so return @@ -156,7 +156,7 @@ def send_email_alert( if message_id is not None: message_path = f"/message/{message_id}" - inbox_url = f"{current_app.config['APP_BASE_URL']}/inbox{message_path}" + inbox_url = f"{settings.APP_BASE_URL}/inbox{message_path}" values = { "FROM_USER_LINK": from_user_link, "FROM_USERNAME": from_username, @@ -171,50 +171,47 @@ def send_email_alert( "MESSAGE_TYPE": message_type, } html_template = get_template("message_alert_en.html", values) - SMTPService._send_message(to_address, subject, html_template) + await SMTPService._send_message(to_address, subject, html_template) return True @staticmethod - def _send_message( + async def _send_message( to_address: str, subject: str, html_message: str, text_message: str = None ): """Helper sends SMTP message""" - from_address = current_app.config["MAIL_DEFAULT_SENDER"] + from_address = settings.MAIL_DEFAULT_SENDER if from_address is None: raise ValueError("Missing TM_EMAIL_FROM_ADDRESS environment variable") - msg = Message() - msg.subject = subject - msg.sender = "{} Tasking Manager <{}>".format( - current_app.config["ORG_CODE"], from_address + msg = MessageSchema( + recipients=[to_address], + subject=subject, + body=html_message, + subtype=MessageType.html, ) - msg.add_recipient(to_address) - - msg.body = text_message - msg.html = html_message + logger.debug(f"Sending email via SMTP {to_address}") + if settings.LOG_LEVEL == "DEBUG": + logger.debug(msg.as_string()) - current_app.logger.debug(f"Sending email via SMTP {to_address}") - if current_app.config["LOG_LEVEL"] == "DEBUG": - current_app.logger.debug(msg.as_string()) else: try: - mail.send(msg) - current_app.logger.debug(f"Email sent {to_address}") + await mail.send_message(msg) + logger.debug(f"Email sent {to_address}") except Exception as e: # ERROR level logs are automatically captured by sentry so that admins are notified - current_app.logger.error( + logger.error( f"{e}: Sending email failed. Please check SMTP configuration" ) @staticmethod def _generate_email_verification_url(email_address: str, user_name: str): """Generate email verification url with unique token""" - entropy = current_app.secret_key if current_app.secret_key else "un1testingmode" + entropy = settings.SECRET_KEY if settings.SECRET_KEY else "un1testingmode" serializer = URLSafeTimedSerializer(entropy) token = serializer.dumps(email_address) - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL verification_params = {"token": token, "username": user_name} verification_url = "{0}/verify-email/?{1}".format( diff --git a/backend/services/messaging/template_service.py b/backend/services/messaging/template_service.py index 4696fa3a1c..026e0ca05c 100644 --- a/backend/services/messaging/template_service.py +++ b/backend/services/messaging/template_service.py @@ -1,7 +1,15 @@ import os import re -from flask import current_app, render_template +from jinja2 import Environment, FileSystemLoader +from loguru import logger + +from backend.config import settings + +# Set up Jinja2 environment +env = Environment( + loader=FileSystemLoader(os.path.join(os.path.dirname(__file__), "templates")), +) def get_txt_template(template_name: str): @@ -17,7 +25,7 @@ def get_txt_template(template_name: str): with open(template_location, mode="r", encoding="utf-8") as template: return template.read() except FileNotFoundError: - current_app.logger.error("Unable open file {0}".format(template_location)) + logger.error("Unable open file {0}".format(template_location)) raise ValueError("Unable open file {0}".format(template_location)) @@ -29,13 +37,19 @@ def get_template(template_name: str, values: dict) -> str: :return: Template as a string """ try: - values["ORG_CODE"] = current_app.config["ORG_CODE"] - values["ORG_NAME"] = current_app.config["ORG_NAME"] - values["ORG_LOGO"] = current_app.config["ORG_LOGO"] - values["APP_BASE_URL"] = current_app.config["APP_BASE_URL"] - return render_template(template_name, values=values) + values["ORG_CODE"] = settings.ORG_CODE + values["ORG_NAME"] = settings.ORG_NAME + values["ORG_LOGO"] = settings.ORG_LOGO + values["APP_BASE_URL"] = settings.APP_BASE_URL + + # Load the template + template = env.get_template(template_name) + + # Render the template as a string + rendered_template = template.render({"values": values}) + return rendered_template except (FileNotFoundError, TypeError): - current_app.logger.error("Unable open file {0}".format(template_name)) + logger.error("Unable open file {0}".format(template_name)) raise ValueError("Unable open file {0}".format(template_name)) @@ -59,6 +73,6 @@ def format_username_link(content): username = name[2:-1] content = content.replace( name, - f'@{username}', + f'@{username}', ) return content diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index e4c5aeab04..5cd74b64ec 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -1,19 +1,33 @@ -from backend.models.postgis.notification import Notification +from databases import Database + from backend.exceptions import NotFound +from backend.models.postgis.notification import Notification +from backend.models.postgis.utils import timestamp class NotificationService: @staticmethod - def update(user_id: int): - notifications = Notification.query.filter( - Notification.user_id == user_id - ).first() + async def update(user_id: int, db: Database): + async with db.transaction(): + query = """ + SELECT * FROM notifications WHERE user_id = :user_id ORDER BY id LIMIT 1 + """ + notifications = await db.fetch_one(query, {"user_id": user_id}) + + if notifications is None: + raise NotFound(sub_code="NOTIFICATIONS_NOT_FOUND", user_id=user_id) - if notifications is None: - raise NotFound(sub_code="NOTIFICATIONS_NOT_FOUND", user_id=user_id) + # Update the notification's date + update_query = """ + UPDATE notifications + SET date = :timestamp + WHERE user_id = :user_id + """ + await db.execute( + update_query, {"user_id": user_id, "timestamp": timestamp()} + ) - notifications.update() - return notifications.unread_count + return notifications["unread_count"] @staticmethod def get_unread_message_count(user_id: int): diff --git a/backend/services/organisation_service.py b/backend/services/organisation_service.py index e1eef65c9e..15a164e7de 100644 --- a/backend/services/organisation_service.py +++ b/backend/services/organisation_service.py @@ -1,29 +1,32 @@ +import json from datetime import datetime -from flask import current_app + +from databases import Database +from fastapi import HTTPException +from loguru import logger from sqlalchemy.exc import IntegrityError -from sqlalchemy import func -from sqlalchemy.sql import extract -from dateutil.relativedelta import relativedelta -from backend import db from backend.exceptions import NotFound from backend.models.dtos.organisation_dto import ( - OrganisationDTO, - NewOrganisationDTO, ListOrganisationsDTO, + NewOrganisationDTO, + OrganisationDTO, + OrganisationTeamsDTO, UpdateOrganisationDTO, ) from backend.models.dtos.stats_dto import ( - OrganizationStatsDTO, OrganizationProjectsStatsDTO, + OrganizationStatsDTO, OrganizationTasksStatsDTO, ) -from backend.models.postgis.campaign import campaign_organisations from backend.models.postgis.organisation import Organisation -from backend.models.postgis.project import Project, ProjectInfo -from backend.models.postgis.task import Task +from backend.models.postgis.statuses import ( + ProjectStatus, + TaskStatus, + TeamJoinMethod, + TeamMemberFunctions, +) from backend.models.postgis.team import TeamVisibility -from backend.models.postgis.statuses import ProjectStatus, TaskStatus from backend.services.users.user_service import UserService @@ -31,45 +34,158 @@ class OrganisationServiceError(Exception): """Custom Exception to notify callers an error occurred when handling organisations""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class OrganisationService: @staticmethod - def get_organisation_by_id(organisation_id: int) -> Organisation: - org = Organisation.get(organisation_id) - - if org is None: + async def get_organisation_by_id(organisation_id: int, db: Database): + # Fetch organisation details + org_query = """ + SELECT + id AS "organisation_id", + name, + slug, + logo, + description, + url, + CASE + WHEN type = 1 THEN 'FREE' + WHEN type = 2 THEN 'DISCOUNTED' + WHEN type = 3 THEN 'FULL_FEE' + ELSE 'UNKNOWN' + END AS type, + subscription_tier + FROM organisations + WHERE id = :organisation_id + """ + org_record = await db.fetch_one( + org_query, values={"organisation_id": organisation_id} + ) + if not org_record: raise NotFound( sub_code="ORGANISATION_NOT_FOUND", organisation_id=organisation_id ) - return org + # Fetch organisation managers + managers_query = """ + SELECT + u.id, + u.username, + u.picture_url + FROM users u + JOIN organisation_managers om ON u.id = om.user_id + WHERE om.organisation_id = :organisation_id + """ + managers_records = await db.fetch_all( + managers_query, values={"organisation_id": organisation_id} + ) + # Assign manager records initially + org_record.managers = managers_records + return org_record @staticmethod - def get_organisation_by_id_as_dto( - organisation_id: int, user_id: int, abbreviated: bool - ): - org = OrganisationService.get_organisation_by_id(organisation_id) - return OrganisationService.get_organisation_dto(org, user_id, abbreviated) + async def get_organisation_by_id_as_dto( + organisation_id: int, user_id: int, abbreviated: bool, db: Database + ) -> OrganisationDTO: + org = await OrganisationService.get_organisation_by_id(organisation_id, db) + return await OrganisationService.get_organisation_dto( + org, user_id, abbreviated, db + ) @staticmethod - def get_organisation_by_slug_as_dto(slug: str, user_id: int, abbreviated: bool): - org = Organisation.query.filter_by(slug=slug).first() - if org is None: + async def get_organisation_by_slug_as_dto( + slug: str, user_id: int, abbreviated: bool, db: Database + ): + org_query = """ + SELECT + id AS "organisation_id", + name, + slug, + logo, + description, + url, + CASE + WHEN type = 1 THEN 'FREE' + WHEN type = 2 THEN 'DISCOUNTED' + WHEN type = 3 THEN 'FULL_FEE' + ELSE 'UNKNOWN' + END AS type, + subscription_tier + FROM organisations + WHERE slug = :slug + """ + org_record = await db.fetch_one(org_query, values={"slug": slug}) + if not org_record: raise NotFound(sub_code="ORGANISATION_NOT_FOUND", slug=slug) - return OrganisationService.get_organisation_dto(org, user_id, abbreviated) + + organisation_id = org_record["organisation_id"] + + # Fetch organisation managers + managers_query = """ + SELECT + u.id, + u.username, + u.picture_url + FROM users u + JOIN organisation_managers om ON u.id = om.user_id + WHERE om.organisation_id = :organisation_id + """ + managers_records = await db.fetch_all( + managers_query, values={"organisation_id": organisation_id} + ) + + org_record.managers = managers_records + return await OrganisationService.get_organisation_dto( + org_record, user_id, abbreviated, db + ) + + @staticmethod + def organisation_as_dto(org) -> OrganisationDTO: + org_dto = OrganisationDTO( + organisation_id=org.organisation_id, + name=org.name, + slug=org.slug, + logo=org.logo, + description=org.description, + url=org.url, + type=org.type, + subscription_tier=org.subscription_tier, + managers=json.loads(org["managers"]), + ) + return org_dto + + @staticmethod + def team_as_dto_inside_org(team) -> OrganisationTeamsDTO: + team_dto = OrganisationTeamsDTO( + team_id=team.team_id, + name=team.name, + description=team.description, + join_method=TeamJoinMethod(team.join_method).name, + members=[ + { + "username": member["username"], + "pictureUrl": member["pictureUrl"], + "function": TeamMemberFunctions(member["function"]).name, + "active": str(member["active"]), + } + for member in json.loads(team["members"]) + ], + visibility=TeamVisibility(team["visibility"]).name, + ) + return team_dto @staticmethod - def get_organisation_dto(org, user_id: int, abbreviated: bool): + async def get_organisation_dto(org, user_id: int, abbreviated: bool, db): if org is None: raise NotFound(sub_code="ORGANISATION_NOT_FOUND") - organisation_dto = org.as_dto(abbreviated) + organisation_dto = Organisation.as_dto(org, abbreviated) if user_id != 0: organisation_dto.is_manager = ( - OrganisationService.can_user_manage_organisation(org.id, user_id) + await OrganisationService.can_user_manage_organisation( + organisation_dto.organisation_id, user_id, db + ) ) else: organisation_dto.is_manager = False @@ -77,20 +193,47 @@ def get_organisation_dto(org, user_id: int, abbreviated: bool): if abbreviated: return organisation_dto + teams_query = """ + SELECT + t.id AS team_id, + t.name, + t.description, + t.join_method, + t.visibility, + COALESCE(json_agg(json_build_object( + 'username', u.username, + 'pictureUrl', u.picture_url, + 'function', tm.function, + 'active', tm.active::text + )) FILTER (WHERE u.id IS NOT NULL), '[]') AS members + FROM teams t + LEFT JOIN team_members tm ON t.id = tm.team_id + LEFT JOIN users u ON tm.user_id = u.id + WHERE t.organisation_id = :org_id + GROUP BY t.id + """ + teams_records = await db.fetch_all( + teams_query, values={"org_id": org.organisation_id} + ) + teams = [ + OrganisationService.team_as_dto_inside_org(record) + for record in teams_records + ] if organisation_dto.is_manager: - organisation_dto.teams = [team.as_dto_inside_org() for team in org.teams] + organisation_dto.teams = teams else: organisation_dto.teams = [ - team.as_dto_inside_org() - for team in org.teams - if team.visibility == TeamVisibility.PUBLIC.value + team for team in teams if team.visibility == "PUBLIC" ] - return organisation_dto @staticmethod - def get_organisation_by_name(organisation_name: str) -> Organisation: - organisation = Organisation.get_organisation_by_name(organisation_name) + async def get_organisation_by_name( + organisation_name: str, db: Database + ) -> Organisation: + organisation = await Organisation.get_organisation_by_name( + organisation_name, db + ) if organisation is None: raise NotFound( @@ -100,188 +243,215 @@ def get_organisation_by_name(organisation_name: str) -> Organisation: return organisation @staticmethod - def get_organisation_name_by_id(organisation_id: int) -> str: - return Organisation.get_organisation_name_by_id(organisation_id) - - @staticmethod - def create_organisation(new_organisation_dto: NewOrganisationDTO) -> int: + async def create_organisation( + new_organisation_dto: NewOrganisationDTO, db: Database + ) -> int: """ Creates a new organisation using an organisation dto :param new_organisation_dto: Organisation DTO :returns: ID of new Organisation """ try: - org = Organisation.create_from_dto(new_organisation_dto) - return org.id + org = await Organisation.create_from_dto(new_organisation_dto, db) + return org except IntegrityError: raise OrganisationServiceError( f"NameExists- Organisation name already exists: {new_organisation_dto.name}" ) @staticmethod - def update_organisation(organisation_dto: UpdateOrganisationDTO) -> Organisation: + async def update_organisation( + organisation_dto: UpdateOrganisationDTO, db: Database + ) -> int: """ Updates an organisation :param organisation_dto: DTO with updated info :returns updated Organisation """ - org = OrganisationService.get_organisation_by_id( - organisation_dto.organisation_id + org = await OrganisationService.get_organisation_by_id( + organisation_dto.organisation_id, db ) - OrganisationService.assert_validate_name(org, organisation_dto.name) - OrganisationService.assert_validate_users(organisation_dto) - org.update(organisation_dto) - return org + await OrganisationService.assert_validate_name(org, organisation_dto.name, db) + await OrganisationService.assert_validate_users(organisation_dto, db) + await Organisation.update(organisation_dto, db) + return org.organisation_id @staticmethod - def delete_organisation(organisation_id: int): + async def delete_organisation(organisation_id: int, db: Database): """Deletes an organisation if it has no projects""" - org = OrganisationService.get_organisation_by_id(organisation_id) - - if org.can_be_deleted(): - org.delete() + if await Organisation.can_be_deleted(organisation_id, db): + delete_organisation_managers_query = """ + DELETE FROM organisation_managers + WHERE organisation_id = :organisation_id + """ + delete_organisation_query = """ + DELETE FROM organisations + WHERE id = :organisation_id + """ + try: + async with db.transaction(): + await db.execute( + query=delete_organisation_managers_query, + values={"organisation_id": organisation_id}, + ) + await db.execute( + query=delete_organisation_query, + values={"organisation_id": organisation_id}, + ) + except Exception as e: + raise HTTPException(status_code=500, detail="Deletion failed") from e else: raise OrganisationServiceError( "Organisation has projects, cannot be deleted" ) @staticmethod - def get_organisations(manager_user_id: int): + async def get_organisations(manager_user_id: int, db: Database): if manager_user_id is None: """Get all organisations""" - return Organisation.get_all_organisations() + return await Organisation.get_all_organisations(db) else: - return Organisation.get_organisations_managed_by_user(manager_user_id) + return await Organisation.get_organisations_managed_by_user( + manager_user_id, db + ) @staticmethod - def get_organisations_as_dto( + async def get_organisations_as_dto( manager_user_id: int, authenticated_user_id: int, omit_managers: bool, omit_stats: bool, + db: Database, ): - orgs = OrganisationService.get_organisations(manager_user_id) + orgs = await OrganisationService.get_organisations(manager_user_id, db) orgs_dto = ListOrganisationsDTO() for org in orgs: - org_dto = org.as_dto(omit_managers) + org_dto = OrganisationService.organisation_as_dto(org) if not omit_stats: year = datetime.today().strftime("%Y") - org_dto.stats = OrganisationService.get_organisation_stats(org.id, year) - if not authenticated_user_id: + stats = await OrganisationService.get_organisation_stats( + org_dto.organisation_id, db, year + ) + org_dto.stats = stats + + if omit_managers or not authenticated_user_id: del org_dto.managers - orgs_dto.organisations.append(org_dto) + orgs_dto.organisations.append(org_dto) return orgs_dto @staticmethod - def get_organisations_managed_by_user(user_id: int): + async def get_organisations_managed_by_user(user_id: int, db): """Get all organisations a user manages""" - if UserService.is_user_an_admin(user_id): - return Organisation.get_all_organisations() + if await UserService.is_user_an_admin(user_id, db): + return await Organisation.get_all_organisations(db) - return Organisation.get_organisations_managed_by_user(user_id) + return await Organisation.get_organisations_managed_by_user(user_id, db) @staticmethod - def get_organisations_managed_by_user_as_dto(user_id: int) -> ListOrganisationsDTO: - orgs = OrganisationService.get_organisations_managed_by_user(user_id) + async def get_organisations_managed_by_user_as_dto( + user_id: int, db: Database + ) -> ListOrganisationsDTO: + orgs = await OrganisationService.get_organisations_managed_by_user(user_id, db) orgs_dto = ListOrganisationsDTO() - orgs_dto.organisations = [org.as_dto() for org in orgs] + + # Fetch managers asynchronously for each organisation + for org in orgs: + orgs_dto.organisations.append(OrganisationService.organisation_as_dto(org)) return orgs_dto @staticmethod - def get_projects_by_organisation_id(organisation_id: int) -> Organisation: - projects = ( - db.session.query(Project.id, ProjectInfo.name) - .join(ProjectInfo) - .filter(Project.organisation_id == organisation_id) - .all() - ) + async def get_organisation_stats( + organisation_id: int, db: Database, year: int = None + ) -> OrganizationStatsDTO: + # Prepare the base projects query + projects_query = f""" + SELECT + COUNT(CASE WHEN status = {ProjectStatus.DRAFT.value} THEN 1 END) AS draft, + COUNT(CASE WHEN status = {ProjectStatus.PUBLISHED.value} THEN 1 END) AS published, + COUNT(CASE WHEN status = {ProjectStatus.ARCHIVED.value} THEN 1 END) AS archived, + COUNT(CASE WHEN status IN ({ProjectStatus.ARCHIVED.value}, {ProjectStatus.PUBLISHED.value}) + AND EXTRACT(YEAR FROM created) = {datetime.now().year} THEN 1 END) AS recent, + COUNT(CASE WHEN status = {ProjectStatus.PUBLISHED.value} + AND last_updated < NOW() - INTERVAL '6 MONTH' THEN 1 END) AS stale + FROM projects + WHERE organisation_id = :organisation_id""" + + projects_values = {"organisation_id": organisation_id} - if projects is None: - raise NotFound( - sub_code="PROJECTS_NOT_FOUND", organisation_id=organisation_id - ) + if year: + start_date = datetime(int(year), 1, 1) + projects_query += " AND created BETWEEN :start_date AND NOW()" + projects_values["start_date"] = start_date + + project_stats = await db.fetch_one(query=projects_query, values=projects_values) + + projects_dto = OrganizationProjectsStatsDTO(**project_stats) + + active_tasks_query = f""" + SELECT + COUNT( + CASE WHEN t.task_status = {TaskStatus.READY.value} THEN 1 END + ) AS ready, + COUNT( + CASE WHEN t.task_status = {TaskStatus.LOCKED_FOR_MAPPING.value} + THEN 1 END + ) AS locked_for_mapping, + COUNT( + CASE WHEN t.task_status = {TaskStatus.MAPPED.value} THEN 1 END + ) AS mapped, + COUNT( + CASE WHEN t.task_status = {TaskStatus.LOCKED_FOR_VALIDATION.value} + THEN 1 END + ) AS locked_for_validation, + COUNT( + CASE WHEN t.task_status = {TaskStatus.VALIDATED.value} THEN 1 END + ) AS validated, + COUNT( + CASE WHEN t.task_status = {TaskStatus.INVALIDATED.value} THEN 1 END + ) AS invalidated, + COUNT( + CASE WHEN t.task_status = {TaskStatus.BADIMAGERY.value} THEN 1 END + ) AS badimagery + FROM tasks t + WHERE t.project_id IN ( + SELECT p.id + FROM projects p + WHERE p.organisation_id = :organisation_id + AND p.status = {ProjectStatus.PUBLISHED.value} + """ - return projects + task_values = {"organisation_id": organisation_id} - @staticmethod - def get_organisation_stats( - organisation_id: int, year: int = None - ) -> OrganizationStatsDTO: - projects = db.session.query( - Project.id, Project.status, Project.last_updated, Project.created - ).filter(Project.organisation_id == organisation_id) if year: - start_date = f"{year}/01/01" - projects = projects.filter(Project.created.between(start_date, func.now())) + start_date = datetime(int(year), 1, 1) + active_tasks_query += " AND p.created BETWEEN :start_date AND NOW()" + task_values["start_date"] = start_date - published_projects = projects.filter( - Project.status == ProjectStatus.PUBLISHED.value - ) - active_tasks = db.session.query( - Task.id, Task.project_id, Task.task_status - ).filter(Task.project_id.in_([i.id for i in published_projects.all()])) - - # populate projects stats - projects_dto = OrganizationProjectsStatsDTO() - projects_dto.draft = projects.filter( - Project.status == ProjectStatus.DRAFT.value - ).count() - projects_dto.published = published_projects.count() - projects_dto.archived = projects.filter( - Project.status == ProjectStatus.ARCHIVED.value - ).count() - projects_dto.recent = projects.filter( - Project.status.in_( - [ProjectStatus.ARCHIVED.value, ProjectStatus.PUBLISHED.value] - ), - extract("year", Project.created) == datetime.now().year, - ).count() - projects_dto.stale = projects.filter( - Project.status == ProjectStatus.PUBLISHED.value, - func.DATE(Project.last_updated) < datetime.now() + relativedelta(months=-6), - ).count() - - # populate tasks stats - tasks_dto = OrganizationTasksStatsDTO() - tasks_dto.ready = active_tasks.filter( - Task.task_status == TaskStatus.READY.value - ).count() - tasks_dto.locked_for_mapping = active_tasks.filter( - Task.task_status == TaskStatus.LOCKED_FOR_MAPPING.value - ).count() - tasks_dto.mapped = active_tasks.filter( - Task.task_status == TaskStatus.MAPPED.value - ).count() - tasks_dto.locked_for_validation = active_tasks.filter( - Task.task_status == TaskStatus.LOCKED_FOR_VALIDATION.value - ).count() - tasks_dto.validated = active_tasks.filter( - Task.task_status == TaskStatus.VALIDATED.value - ).count() - tasks_dto.invalidated = active_tasks.filter( - Task.task_status == TaskStatus.INVALIDATED.value - ).count() - tasks_dto.badimagery = active_tasks.filter( - Task.task_status == TaskStatus.BADIMAGERY.value - ).count() - - # populate and return main dto + active_tasks_query += ")" + task_stats = await db.fetch_one(query=active_tasks_query, values=task_values) + tasks_dto = OrganizationTasksStatsDTO(**task_stats) + + # Populate and return the main DTO stats_dto = OrganizationStatsDTO() stats_dto.projects = projects_dto stats_dto.active_tasks = tasks_dto + return stats_dto @staticmethod - def assert_validate_name(org: Organisation, name: str): + async def assert_validate_name(org: Organisation, name: str, db): """Validates that the organisation name doesn't exist""" - if org.name != name and Organisation.get_organisation_by_name(name) is not None: + if ( + org.name != name + and await Organisation.get_organisation_by_name(name, db) is not None + ): raise OrganisationServiceError( f"NameExists- Organisation name already exists: {name}" ) @staticmethod - def assert_validate_users(organisation_dto: OrganisationDTO): + async def assert_validate_users(organisation_dto: OrganisationDTO, db): """Validates that the users exist""" if organisation_dto.managers and len(organisation_dto.managers) == 0: raise OrganisationServiceError( @@ -292,62 +462,75 @@ def assert_validate_users(organisation_dto: OrganisationDTO): managers = [] for user in organisation_dto.managers: try: - admin = UserService.get_user_by_username(user) + admin = await UserService.get_user_by_username(user, db) except NotFound: raise NotFound(sub_code="USER_NOT_FOUND", username=user) managers.append(admin.username) - organisation_dto.managers = managers @staticmethod - def can_user_manage_organisation(organisation_id: int, user_id: int): + async def can_user_manage_organisation( + organisation_id: int, user_id: int, db: Database + ): """Check that the user is an admin for the org or a global admin""" - if UserService.is_user_an_admin(user_id): + if await UserService.is_user_an_admin(user_id, db): return True else: - return OrganisationService.is_user_an_org_manager(organisation_id, user_id) + return await OrganisationService.is_user_an_org_manager( + organisation_id, user_id, db + ) @staticmethod - def is_user_an_org_manager(organisation_id: int, user_id: int): + async def is_user_an_org_manager(organisation_id: int, user_id: int, db: Database): """Check that the user is an manager for the org""" - - org = Organisation.get(organisation_id) - - if org is None: - raise NotFound( - sub_code="ORGANISATION_NOT_FOUND", organisation_id=organisation_id - ) - user = UserService.get_user_by_id(user_id) - - return user in org.managers + # Fetch organisation managers' IDs + managers_query = """ + SELECT + u.id + FROM users u + JOIN organisation_managers om ON u.id = om.user_id + WHERE om.organisation_id = :organisation_id + """ + managers_records = await db.fetch_all( + managers_query, values={"organisation_id": organisation_id} + ) + # Extract the list of IDs from the records + managers_ids = [record.id for record in managers_records] + user = await UserService.get_user_by_id(user_id, db) + return user.id in managers_ids @staticmethod - def get_campaign_organisations_as_dto(campaign_id: int, user_id: int): + async def get_campaign_organisations_as_dto( + campaign_id: int, user_id: int, db: Database + ): + """ + Returns organisations under a particular campaign. """ - Returns organisations under a particular campaign + query = """ + SELECT o.id, o.name, o.logo, o.url + FROM organisations o + JOIN campaign_organisations co ON o.id = co.organisation_id + WHERE co.campaign_id = :campaign_id """ + orgs = await db.fetch_all(query, values={"campaign_id": campaign_id}) + organisation_list_dto = ListOrganisationsDTO() - orgs = ( - Organisation.query.join(campaign_organisations) - .filter(campaign_organisations.c.campaign_id == campaign_id) - .all() - ) for org in orgs: + logged_in = False if user_id != 0: - logged_in = OrganisationService.can_user_manage_organisation( - org.id, user_id + logged_in = await OrganisationService.can_user_manage_organisation( + org["id"], user_id ) - else: - logged_in = False - - organisation_dto = OrganisationDTO() - organisation_dto.organisation_id = org.id - organisation_dto.name = org.name - organisation_dto.logo = org.logo - organisation_dto.url = org.url - organisation_dto.is_manager = logged_in + + organisation_dto = OrganisationDTO( + organisation_id=org["id"], + name=org["name"], + logo=org["logo"], + url=org["url"], + is_manager=logged_in, + ) organisation_list_dto.organisations.append(organisation_dto) diff --git a/backend/services/partner_service.py b/backend/services/partner_service.py index cde96e6383..20e88f2f4c 100644 --- a/backend/services/partner_service.py +++ b/backend/services/partner_service.py @@ -1,5 +1,9 @@ -from flask import current_app import json + +from databases import Database +from fastapi.responses import JSONResponse +from loguru import logger + from backend.models.dtos.partner_dto import PartnerDTO from backend.models.postgis.partner import Partner @@ -8,22 +12,21 @@ class PartnerServiceError(Exception): """Custom Exception to notify callers an error occurred when handling partners""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class PartnerService: @staticmethod - def get_partner_by_id(partner_id: int) -> Partner: - return Partner.get_by_id(partner_id) + async def get_partner_by_id(partner_id: int, db: Database): + return await Partner.get_by_id(partner_id, db) @staticmethod - def get_partner_by_permalink(permalink: str) -> Partner: - return Partner.get_by_permalink(permalink) + async def get_partner_by_permalink(permalink: str, db: Database) -> Partner: + return await Partner.get_by_permalink(permalink, db) @staticmethod - def create_partner(data): - """Create a new partner in database""" + async def create_partner(data, db: Database) -> int: + """Create a new partner in the database""" website_links = [] for i in range(1, 6): name_key = f"name_{i}" @@ -32,34 +35,54 @@ def create_partner(data): url = data.get(url_key) if name and url: website_links.append({"name": name, "url": url}) - new_partner = Partner( - name=data.get("name"), - primary_hashtag=data.get("primary_hashtag"), - secondary_hashtag=data.get("secondary_hashtag"), - logo_url=data.get("logo_url"), - link_meta=data.get("link_meta"), - link_x=data.get("link_x"), - link_instagram=data.get("link_instagram"), - current_projects=data.get("current_projects"), - permalink=data.get("permalink"), - website_links=json.dumps(website_links), - mapswipe_group_id=data.get("mapswipe_group_id"), - ) - new_partner.create() - return new_partner + + query = """ + INSERT INTO partners ( + name, primary_hashtag, secondary_hashtag, logo_url, link_meta, + link_x, link_instagram, current_projects, permalink, + website_links, mapswipe_group_id + ) VALUES ( + :name, :primary_hashtag, :secondary_hashtag, :logo_url, :link_meta, + :link_x, :link_instagram, :current_projects, :permalink, + :website_links, :mapswipe_group_id + ) RETURNING id + """ + + values = { + "name": data.get("name"), + "primary_hashtag": data.get("primary_hashtag"), + "secondary_hashtag": data.get("secondary_hashtag"), + "logo_url": data.get("logo_url"), + "link_meta": data.get("link_meta"), + "link_x": data.get("link_x"), + "link_instagram": data.get("link_instagram"), + "current_projects": data.get("current_projects"), + "permalink": data.get("permalink"), + "website_links": json.dumps(website_links), + "mapswipe_group_id": data.get("mapswipe_group_id"), + } + + new_partner_id = await db.execute(query, values) + return new_partner_id @staticmethod - def delete_partner(partner_id: int): - partner = Partner.get_by_id(partner_id) + async def delete_partner(partner_id: int, db: Database): + partner = await Partner.get_by_id(partner_id, db) if partner: - partner.delete() - return {"Success": "Team deleted"}, 200 + delete_partner_query = """ + DELETE FROM partners WHERE id = :partner_id + """ + await db.execute(delete_partner_query, {"partner_id": partner_id}) + return JSONResponse(content={"Success": "Team deleted"}, status_code=200) else: - return {"Error": "Partner cannot be deleted"}, 400 + return JSONResponse( + content={"Error": "Partner cannot be deleted"}, status_code=400 + ) @staticmethod - def update_partner(partner_id: int, data: dict) -> Partner: - partner = Partner.get_by_id(partner_id) + async def update_partner(partner_id: int, data: dict, db: Database) -> dict: + await Partner.get_by_id(partner_id, db) + # Handle dynamic website links from name_* and url_* website_links = [] for key, value in data.items(): if key.startswith("name_"): @@ -67,12 +90,36 @@ def update_partner(partner_id: int, data: dict) -> Partner: url_key = f"url_{index}" if url_key in data and value.strip(): website_links.append({"name": value, "url": data[url_key]}) + + set_clauses = [] + params = {"partner_id": partner_id} + for key, value in data.items(): - if hasattr(partner, key): - setattr(partner, key, value) - partner.website_links = json.dumps(website_links) - partner.save() - return partner + # Exclude name_* and url_* fields from direct update + if key.startswith("name_") or key.startswith("url_"): + continue + set_clauses.append(f"{key} = :{key}") + params[key] = value + + if website_links: + set_clauses.append("website_links = :website_links") + params["website_links"] = json.dumps(website_links) + + set_clause = ", ".join(set_clauses) + query = f""" + UPDATE partners + SET {set_clause} + WHERE id = :partner_id + RETURNING * + """ + + updated_partner = await db.fetch_one(query, params) + if not updated_partner: + raise PartnerServiceError(f"Failed to update Partner with ID {partner_id}.") + partner_dict = dict(updated_partner) + if "website_links" in partner_dict and partner_dict["website_links"]: + partner_dict["website_links"] = json.loads(partner_dict["website_links"]) + return partner_dict @staticmethod def get_partner_dto_by_id(partner: int, request_partner: int) -> PartnerDTO: @@ -83,6 +130,6 @@ def get_partner_dto_by_id(partner: int, request_partner: int) -> PartnerDTO: return partner.as_dto() @staticmethod - def get_all_partners(): + async def get_all_partners(db: Database): """Get all partners""" - return Partner.get_all_partners() + return await Partner.get_all_partners(db) diff --git a/backend/services/project_admin_service.py b/backend/services/project_admin_service.py index 13e34bb6fb..ff1260c431 100644 --- a/backend/services/project_admin_service.py +++ b/backend/services/project_admin_service.py @@ -1,47 +1,50 @@ import json -import threading + import geojson -from flask import current_app +from databases import Database +from fastapi import BackgroundTasks +from loguru import logger +from backend.config import settings from backend.exceptions import NotFound from backend.models.dtos.project_dto import ( DraftProjectDTO, - ProjectDTO, ProjectCommentsDTO, + ProjectDTO, ProjectSearchDTO, ) -from backend.models.postgis.project import Project, Task, ProjectStatus +from backend.models.postgis.project import Project, ProjectStatus, Task from backend.models.postgis.statuses import TaskCreationMode, TeamRoles -from backend.models.postgis.task import TaskHistory, TaskStatus, TaskAction +from backend.models.postgis.task import TaskAction, TaskHistory, TaskStatus from backend.models.postgis.user import User from backend.models.postgis.utils import InvalidData, InvalidGeoJson from backend.services.grid.grid_service import GridService from backend.services.license_service import LicenseService from backend.services.messaging.message_service import MessageService -from backend.services.users.user_service import UserService from backend.services.organisation_service import OrganisationService from backend.services.team_service import TeamService +from backend.services.users.user_service import UserService class ProjectAdminServiceError(Exception): """Custom Exception to notify callers an error occurred when validating a Project""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectStoreError(Exception): """Custom Exception to notify callers an error occurred with database CRUD operations""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectAdminService: @staticmethod - def create_draft_project(draft_project_dto: DraftProjectDTO) -> int: + async def create_draft_project( + draft_project_dto: DraftProjectDTO, db: Database + ) -> int: """ Validates and then persists draft projects in the DB :param draft_project_dto: Draft Project DTO with data from API @@ -49,15 +52,15 @@ def create_draft_project(draft_project_dto: DraftProjectDTO) -> int: :returns ID of new draft project """ user_id = draft_project_dto.user_id - is_admin = UserService.is_user_an_admin(user_id) - user_orgs = OrganisationService.get_organisations_managed_by_user_as_dto( - user_id + is_admin = await UserService.is_user_an_admin(user_id, db) + user_orgs = await OrganisationService.get_organisations_managed_by_user_as_dto( + user_id, db ) is_org_manager = len(user_orgs.organisations) > 0 # First things first, we need to validate that the author_id is a PM. issue #1715 if not (is_admin or is_org_manager): - user = UserService.get_user_by_id(user_id) + user = await UserService.get_user_by_id(user_id, db) raise ( ProjectAdminServiceError( f"NotPermittedToCreate- User {user.username} is not permitted to create project" @@ -66,16 +69,19 @@ def create_draft_project(draft_project_dto: DraftProjectDTO) -> int: # If we're cloning we'll copy all the project details from the clone, otherwise create brand new project if draft_project_dto.cloneFromProjectId: - draft_project = Project.clone(draft_project_dto.cloneFromProjectId, user_id) + draft_project = await Project.clone( + draft_project_dto.cloneFromProjectId, user_id, db + ) else: draft_project = Project() - org = OrganisationService.get_organisation_by_id( - draft_project_dto.organisation + org = await OrganisationService.get_organisation_by_id( + draft_project_dto.organisation, db ) draft_project_dto.organisation = org + draft_project.create_draft_project(draft_project_dto) - draft_project.set_project_aoi(draft_project_dto) + await draft_project.set_project_aoi(draft_project_dto, db) # if arbitrary_tasks requested, create tasks from aoi otherwise use tasks in DTO if draft_project_dto.has_arbitrary_tasks: @@ -85,27 +91,30 @@ def create_draft_project(draft_project_dto: DraftProjectDTO) -> int: draft_project.task_creation_mode = TaskCreationMode.ARBITRARY.value else: tasks = draft_project_dto.tasks - ProjectAdminService._attach_tasks_to_project(draft_project, tasks) + + await ProjectAdminService._attach_tasks_to_project(draft_project, tasks, db) + draft_project.set_country_info() if draft_project_dto.cloneFromProjectId: - draft_project.save() # Update the clone + draft_project.set_default_changeset_comment() + await draft_project.save(db) # Update the clone + return draft_project.id else: - draft_project.create() # Create the new project - - draft_project.set_default_changeset_comment() - draft_project.set_country_info() - return draft_project.id + project_id = await Project.create( + draft_project, draft_project_dto.project_name, db + ) # Create the new project + return project_id @staticmethod def _set_default_changeset_comment(draft_project: Project): """Sets the default changesset comment when project created""" - default_comment = current_app.config["DEFAULT_CHANGESET_COMMENT"] + default_comment = settings.DEFAULT_CHANGESET_COMMENT draft_project.changeset_comment = f"{default_comment}-{draft_project.id}" draft_project.save() @staticmethod - def _get_project_by_id(project_id: int) -> Project: - project = Project.get(project_id) + async def _get_project_by_id(project_id: int, db: Database) -> Project: + project = await Project.get(project_id, db) if project is None: raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) @@ -113,13 +122,15 @@ def _get_project_by_id(project_id: int) -> Project: return project @staticmethod - def get_project_dto_for_admin(project_id: int) -> ProjectDTO: + async def get_project_dto_for_admin(project_id: int, db: Database) -> ProjectDTO: """Get the project as DTO for project managers""" - project = ProjectAdminService._get_project_by_id(project_id) - return project.as_dto_for_admin(project_id) + await Project.exists(project_id, db) + return await Project.as_dto_for_admin(project_id, db) @staticmethod - def update_project(project_dto: ProjectDTO, authenticated_user_id: int): + async def update_project( + project_dto: ProjectDTO, authenticated_user_id: int, db: Database + ): project_id = project_dto.project_id if project_dto.project_status == ProjectStatus.PUBLISHED.name: @@ -128,14 +139,16 @@ def update_project(project_dto: ProjectDTO, authenticated_user_id: int): ) if project_dto.license_id: - ProjectAdminService._validate_imagery_licence(project_dto.license_id) + await ProjectAdminService._validate_imagery_licence( + project_dto.license_id, db + ) # To be handled before reaching this function - if ProjectAdminService.is_user_action_permitted_on_project( - authenticated_user_id, project_id + if await ProjectAdminService.is_user_action_permitted_on_project( + authenticated_user_id, project_id, db ): - project = ProjectAdminService._get_project_by_id(project_id) - project.update(project_dto) + project = await ProjectAdminService._get_project_by_id(project_id, db) + await Project.update(project, project_dto, db) else: raise ValueError( str(project_id) @@ -145,29 +158,29 @@ def update_project(project_dto: ProjectDTO, authenticated_user_id: int): return project @staticmethod - def _validate_imagery_licence(license_id: int): + async def _validate_imagery_licence(license_id: int, db: Database): """Ensures that the suppliced license Id actually exists""" try: - LicenseService.get_license_as_dto(license_id) + await LicenseService.get_license_as_dto(license_id, db) except NotFound: raise ProjectAdminServiceError( f"RequireLicenseId- LicenseId {license_id} not found" ) @staticmethod - def delete_project(project_id: int, authenticated_user_id: int): + async def delete_project(project_id: int, authenticated_user_id: int, db: Database): """Deletes project if it has no completed tasks""" - project = ProjectAdminService._get_project_by_id(project_id) - is_admin = UserService.is_user_an_admin(authenticated_user_id) - user_orgs = OrganisationService.get_organisations_managed_by_user_as_dto( - authenticated_user_id + project = await ProjectAdminService._get_project_by_id(project_id, db) + is_admin = await UserService.is_user_an_admin(authenticated_user_id, db) + user_orgs = await OrganisationService.get_organisations_managed_by_user_as_dto( + authenticated_user_id, db ) is_org_manager = len(user_orgs.organisations) > 0 if is_admin or is_org_manager: - if project.can_be_deleted(): - project.delete() + if await Project.can_be_deleted(project, db): + await Project.delete(project, db) else: raise ProjectAdminServiceError( "HasMappedTasks- Project has mapped tasks, cannot be deleted" @@ -178,30 +191,58 @@ def delete_project(project_id: int, authenticated_user_id: int): ) @staticmethod - def reset_all_tasks(project_id: int, user_id: int): + async def reset_all_tasks(project_id: int, user_id: int, db: Database): """Resets all tasks on project, preserving history""" - tasks_to_reset = Task.query.filter( - Task.project_id == project_id, - Task.task_status != TaskStatus.READY.value, - ).all() + # Fetch tasks that are not in the READY state + query = """ + SELECT id, task_status + FROM tasks + WHERE project_id = :project_id + AND task_status != :ready_status + """ + tasks_to_reset = await db.fetch_all( + query=query, + values={ + "project_id": project_id, + "ready_status": TaskStatus.READY.value, + }, + ) + + # Reset each task and preserve history for task in tasks_to_reset: - task.set_task_history( - TaskAction.COMMENT, user_id, "Task reset", TaskStatus.READY + task_id = task["id"] + + # Add a history entry for the task reset + await Task.set_task_history( + task_id=task_id, + project_id=project_id, + user_id=user_id, + action=TaskAction.COMMENT, + db=db, + comment="Task reset", + new_state=TaskStatus.READY, ) - task.reset_task(user_id) - # Reset project counters - project = ProjectAdminService._get_project_by_id(project_id) - project.tasks_mapped = 0 - project.tasks_validated = 0 - project.tasks_bad_imagery = 0 - project.save() + # Reset the task's status to READY + await Task.reset_task( + task_id=task_id, project_id=project_id, user_id=user_id, db=db + ) + + # Reset project counters using raw SQL + project_update_query = """ + UPDATE projects + SET tasks_mapped = 0, + tasks_validated = 0, + tasks_bad_imagery = 0 + WHERE id = :project_id + """ + await db.execute(query=project_update_query, values={"project_id": project_id}) @staticmethod - def get_all_comments(project_id: int) -> ProjectCommentsDTO: + async def get_all_comments(project_id: int, db: Database) -> ProjectCommentsDTO: """Gets all comments mappers, validators have added to tasks associated with project""" - comments = TaskHistory.get_all_comments(project_id) + comments = await TaskHistory.get_all_comments(project_id, db) if len(comments.comments) == 0: raise NotFound(sub_code="COMMENTS_NOT_FOUND", project_id=project_id) @@ -209,7 +250,9 @@ def get_all_comments(project_id: int) -> ProjectCommentsDTO: return comments @staticmethod - def _attach_tasks_to_project(draft_project: Project, tasks_geojson): + async def _attach_tasks_to_project( + draft_project: Project, tasks_geojson, db: Database + ): """ Validates then iterates over the array of tasks and attach them to the draft project :param draft_project: Draft project in scope @@ -260,8 +303,7 @@ def _validate_default_locale(default_locale, project_info_locales): raise ProjectAdminServiceError( "InfoForLocaleRequired- Project Info for Default Locale not provided" ) - - for attr, value in default_info.items(): + for attr, value in default_info.dict().items(): if attr == "per_task_instructions": continue # Not mandatory field @@ -275,81 +317,109 @@ def _validate_default_locale(default_locale, project_info_locales): return True # Indicates valid default locale for unit testing @staticmethod - def get_projects_for_admin( - admin_id: int, preferred_locale: str, search_dto: ProjectSearchDTO + async def get_projects_for_admin( + admin_id: int, preferred_locale: str, search_dto: ProjectSearchDTO, db: Database ): """Get all projects for provided admin""" - return Project.get_projects_for_admin(admin_id, preferred_locale, search_dto) + return await Project.get_projects_for_admin( + admin_id, preferred_locale, search_dto, db + ) @staticmethod - def transfer_project_to(project_id: int, transfering_user_id: int, username: str): + async def transfer_project_to( + project_id: int, + transfering_user_id: int, + username: str, + db: Database, + background_tasks: BackgroundTasks, + ): """Transfers project from old owner (transfering_user_id) to new owner (username)""" - project = ProjectAdminService._get_project_by_id(project_id) - new_owner = UserService.get_user_by_username(username) - # No operation is required if the new owner is same as old owner - if username == project.author.username: - return - - # Check permissions for the user (transferring_user_id) who initiatied the action - is_admin = UserService.is_user_an_admin(transfering_user_id) - is_author = UserService.is_user_the_project_author( - transfering_user_id, project.author_id - ) - is_org_manager = OrganisationService.is_user_an_org_manager( - project.organisation_id, transfering_user_id - ) - if not (is_admin or is_author or is_org_manager): - raise ProjectAdminServiceError( - "TransferPermissionError- User does not have permissions to transfer project" + async with db.transaction(): + project = await Project.get(project_id, db) + new_owner = await UserService.get_user_by_username(username, db) + author_id = project.author_id + if not author_id: + raise ProjectAdminServiceError( + "TransferPermissionError- User does not have permissions to transfer project" + ) + author = await User.get_by_id(author_id, db) + if username == author.username: + return + + is_admin = await UserService.is_user_an_admin(transfering_user_id, db) + + is_author = UserService.is_user_the_project_author( + transfering_user_id, project.author_id + ) + is_org_manager = await OrganisationService.is_user_an_org_manager( + project.organisation_id, transfering_user_id, db ) + if not (is_admin or is_author or is_org_manager): + raise ProjectAdminServiceError( + "TransferPermissionError- User does not have permissions to transfer project" + ) - # Check permissions for the new owner - must be project's org manager - is_new_owner_org_manager = OrganisationService.is_user_an_org_manager( - project.organisation_id, new_owner.id - ) - is_new_owner_admin = UserService.is_user_an_admin(new_owner.id) - if not (is_new_owner_org_manager or is_new_owner_admin): - error_message = ( - "InvalidNewOwner- New owner must be project's org manager or TM admin" + is_new_owner_org_manager = await OrganisationService.is_user_an_org_manager( + project.organisation_id, new_owner.id, db + ) + is_new_owner_admin = await UserService.is_user_an_admin(new_owner.id, db) + if not (is_new_owner_org_manager or is_new_owner_admin): + error_message = "InvalidNewOwner- New owner must be project's org manager or TM admin" + logger.debug(error_message) + raise ValueError(error_message) + else: + transferred_by = await User.get_by_id(transfering_user_id, db) + transferred_by = transferred_by.username + project.author_id = new_owner.id + await Project.update_project_author(project_id, new_owner.id, db) + + background_tasks.add_task( + MessageService.send_project_transfer_message, + project_id, + username, + transferred_by, ) - if current_app: - current_app.logger.debug(error_message) - raise ValueError(error_message) - else: - transferred_by = User.get_by_id(transfering_user_id).username - project.author_id = new_owner.id - project.save() - threading.Thread( - target=MessageService.send_project_transfer_message, - args=(project_id, username, transferred_by), - ).start() @staticmethod - def is_user_action_permitted_on_project( - authenticated_user_id: int, project_id: int + async def is_user_action_permitted_on_project( + authenticated_user_id: int, project_id: int, db: Database ) -> bool: """Is user action permitted on project""" - project = Project.get(project_id) - if project is None: + # Fetch the project details + project_query = """ + SELECT author_id, organisation_id + FROM projects + WHERE id = :project_id + """ + project = await db.fetch_one( + query=project_query, values={"project_id": project_id} + ) + if not project: raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) + author_id = project.author_id - allowed_roles = [TeamRoles.PROJECT_MANAGER.value] + organisation_id = project.organisation_id - is_admin = UserService.is_user_an_admin(authenticated_user_id) - is_author = UserService.is_user_the_project_author( - authenticated_user_id, author_id - ) + is_admin = await UserService.is_user_an_admin(authenticated_user_id, db) + + # Check if the user is the project author + is_author = authenticated_user_id == author_id is_org_manager = False is_manager_team = False + # If the user is neither an admin nor the author, check further permissions if not (is_admin or is_author): - if hasattr(project, "organisation_id") and project.organisation_id: - org_id = project.organisation_id - is_org_manager = OrganisationService.is_user_an_org_manager( - org_id, authenticated_user_id + if organisation_id: + # Check if the user is an organisation manager + is_org_manager = await OrganisationService.is_user_an_org_manager( + organisation_id, authenticated_user_id, db ) if not is_org_manager: - is_manager_team = TeamService.check_team_membership( - project_id, allowed_roles, authenticated_user_id + # Check if the user is a project manager in the team + is_manager_team = await TeamService.check_team_membership( + project_id, + [TeamRoles.PROJECT_MANAGER.value], + authenticated_user_id, + db, ) return is_admin or is_author or is_org_manager or is_manager_team diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 1cc8ce5a94..c7436648f4 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -1,55 +1,64 @@ -from flask import current_app -from backend.exceptions import NotFound, BadRequest +import datetime +from typing import List, Optional + +from databases import Database +from loguru import logger + +from backend.exceptions import BadRequest, NotFound +from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.postgis.partner import Partner from backend.models.postgis.project_partner import ( + ProjectPartnerAction, ProjectPartnership, ProjectPartnershipHistory, - ProjectPartnerAction, ) -from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO - -from backend.models.postgis.partner import Partner - -from typing import List, Optional -import datetime class ProjectPartnershipServiceError(Exception): """Custom Exception to notify callers an error occurred when handling project partnerships""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectPartnershipService: @staticmethod - def get_partnership_as_dto(partnership_id: int) -> ProjectPartnershipDTO: - partnership = ProjectPartnership.get_by_id(partnership_id) + async def get_partnership_as_dto( + partnership_id: int, db: Database + ) -> ProjectPartnershipDTO: + partnership = await ProjectPartnership.get_by_id(partnership_id, db) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id ) - partnership_dto = ProjectPartnershipDTO() - partnership_dto.id = partnership.id - partnership_dto.project_id = partnership.project_id - partnership_dto.partner_id = partnership.partner_id - partnership_dto.started_on = partnership.started_on - partnership_dto.ended_on = partnership.ended_on + partnership_dto = ProjectPartnershipDTO( + id=partnership.id, + project_id=partnership.project_id, + partner_id=partnership.partner_id, + started_on=partnership.started_on, + ended_on=partnership.ended_on, + ) return partnership_dto @staticmethod - def get_partnerships_by_project(project_id: int) -> List[ProjectPartnershipDTO]: - partnerships = ProjectPartnership.query.filter( - ProjectPartnership.project_id == project_id - ).all() - - return list( - map(lambda partnership: partnership.as_dto().to_primitive(), partnerships) - ) + async def get_partnerships_by_project( + project_id: int, db: Database + ) -> List[ProjectPartnershipDTO]: + """ + Retrieves all partnerships for a specific project ID. + """ + query = """ + SELECT id, project_id, partner_id, started_on, ended_on + FROM project_partnerships + WHERE project_id = :project_id + """ + rows = await db.fetch_all(query, values={"project_id": project_id}) + return [ProjectPartnershipDTO(**row) for row in rows] @staticmethod - def create_partnership( + async def create_partnership( + db: Database, project_id: int, partner_id: int, started_on: Optional[datetime.datetime], @@ -72,7 +81,7 @@ def create_partnership( ended_on=partnership.ended_on, ) - partnership_id = partnership.create() + partnership_id = await partnership.create(db) partnership_history = ProjectPartnershipHistory() partnership_history.partnership_id = partnership_id @@ -80,17 +89,19 @@ def create_partnership( partnership_history.partner_id = partner_id partnership_history.started_on_new = partnership.started_on partnership_history.ended_on_new = partnership.ended_on - partnership_history.create() + await partnership_history.create(db) return partnership_id @staticmethod - def update_partnership_time_range( + async def update_partnership_time_range( + db: Database, partnership_id: int, started_on: Optional[datetime.datetime], ended_on: Optional[datetime.datetime], ) -> ProjectPartnership: - partnership = ProjectPartnership.get_by_id(partnership_id) + partnership_record = await ProjectPartnership.get_by_id(partnership_id, db) + partnership = ProjectPartnership(**partnership_record) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id @@ -126,14 +137,15 @@ def update_partnership_time_range( ended_on=partnership.ended_on, ) - partnership.save() - partnership_history.create() + await partnership.save(db) + await partnership_history.create(db) return partnership @staticmethod - def delete_partnership(partnership_id: int): - partnership = ProjectPartnership.get_by_id(partnership_id) + async def delete_partnership(partnership_id: int, db: Database): + partnership_record = await ProjectPartnership.get_by_id(partnership_id, db) + partnership = ProjectPartnership(**partnership_record) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id @@ -146,9 +158,8 @@ def delete_partnership(partnership_id: int): partnership_history.started_on_old = partnership.started_on partnership_history.ended_on_old = partnership.ended_on partnership_history.action = ProjectPartnerAction.DELETE.value - partnership_history.create() - - partnership.delete() + await partnership_history.create(db) + await partnership.delete(db) @staticmethod def get_partners_by_project(project_id: int) -> List[Partner]: diff --git a/backend/services/project_search_service.py b/backend/services/project_search_service.py index bdb7633dbc..9a37ece8eb 100644 --- a/backend/services/project_search_service.py +++ b/backend/services/project_search_service.py @@ -1,51 +1,40 @@ -import pandas as pd -from backend.models.postgis.user import User -from flask import current_app import math +from typing import List + import geojson +import pandas as pd +from aiocache import Cache, cached +from databases import Database +from fastapi import HTTPException from geoalchemy2 import shape -from sqlalchemy import func, distinct, desc, or_, and_ +from loguru import logger from shapely.geometry import Polygon, box -from cachetools import TTLCache, cached -from backend import db -from backend.exceptions import NotFound from backend.api.utils import validate_date_input +from backend.exceptions import NotFound from backend.models.dtos.project_dto import ( - ProjectSearchDTO, - ProjectSearchResultsDTO, ListSearchResultDTO, Pagination, ProjectSearchBBoxDTO, + ProjectSearchDTO, + ProjectSearchResultsDTO, ) -from backend.models.postgis.project import Project, ProjectInfo, ProjectTeams -from backend.models.postgis.partner import Partner +from backend.models.postgis.project import Project, ProjectInfo from backend.models.postgis.statuses import ( - ProjectStatus, MappingLevel, + MappingPermission, MappingTypes, + ProjectDifficulty, ProjectPriority, - UserRole, + ProjectStatus, TeamRoles, + UserRole, ValidationPermission, - MappingPermission, - ProjectDifficulty, -) -from backend.models.postgis.project_partner import ProjectPartnership -from backend.models.postgis.campaign import Campaign -from backend.models.postgis.organisation import Organisation -from backend.models.postgis.task import TaskHistory -from backend.models.postgis.utils import ( - ST_Intersects, - ST_MakeEnvelope, - ST_Transform, - ST_Area, ) -from backend.models.postgis.interests import project_interests from backend.services.users.user_service import UserService -search_cache = TTLCache(maxsize=128, ttl=300) -csv_download_cache = TTLCache(maxsize=16, ttl=600) +# search_cache = TTLCache(maxsize=128, ttl=300) +# csv_download_cache = TTLCache(maxsize=16, ttl=600) # max area allowed for passed in bbox, calculation shown to help future maintenance # client resolution (mpp)* arbitrary large map size on a large screen in pixels * 50% buffer, all squared @@ -56,122 +45,145 @@ class ProjectSearchServiceError(Exception): """Custom Exception to notify callers an error occurred when handling mapping""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class BBoxTooBigError(Exception): """Custom Exception to notify callers an error occurred when handling mapping""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectSearchService: @staticmethod - def create_search_query(user=None, as_csv: bool = False): + async def create_search_query(db, user=None, as_csv: bool = False): + # Base query for fetching project details if as_csv: - query = ( - db.session.query( - Project.id.label("id"), - ProjectInfo.name.label("project_name"), - Project.difficulty, - Project.priority, - Project.default_locale, - Project.centroid.ST_AsGeoJSON().label("centroid"), - Project.organisation_id, - Project.tasks_bad_imagery, - Project.tasks_mapped, - Project.tasks_validated, - Project.percent_mapped, - Project.percent_validated, - Project.status, - Project.total_tasks, - Project.last_updated, - Project.due_date, - Project.country, - Organisation.name.label("organisation_name"), - Organisation.logo.label("organisation_logo"), - User.name.label("author_name"), - User.username.label("author_username"), - Project.created.label("creation_date"), - func.coalesce( - func.sum(func.ST_Area(Project.geometry, True) / 1000000) - ).label("total_area"), - ) - .filter(Project.geometry is not None) - .outerjoin(Organisation, Organisation.id == Project.organisation_id) - .outerjoin(User, User.id == Project.author_id) - .group_by( - Organisation.id, - Project.id, - ProjectInfo.name, - User.username, - User.name, - ) - ) - else: - query = ( - db.session.query( - Project.id.label("id"), - Project.difficulty, - Project.priority, - Project.default_locale, - Project.centroid.ST_AsGeoJSON().label("centroid"), - Project.organisation_id, - Project.tasks_bad_imagery, - Project.tasks_mapped, - Project.tasks_validated, - Project.status, - Project.total_tasks, - Project.last_updated, - Project.due_date, - Project.country, - Organisation.name.label("organisation_name"), - Organisation.logo.label("organisation_logo"), - ) - .filter(Project.geometry is not None) - .outerjoin(Organisation, Organisation.id == Project.organisation_id) - .group_by(Organisation.id, Project.id) - ) + query = """ + SELECT + p.id AS id, + p.priority, + p.difficulty, + p.default_locale, + p.status, + p.last_updated, + p.due_date, + p.total_tasks, + p.tasks_mapped, + p.tasks_validated, + p.tasks_bad_imagery, + u.name AS author_name, + u.username AS author_username, + o.name AS organisation_name, + ROUND( + COALESCE( + (p.tasks_mapped + p.tasks_validated) * 100.0 / + NULLIF(p.total_tasks - p.tasks_bad_imagery, 0), + 0 + ), + 2 + ) AS percent_mapped, + ROUND(COALESCE( + p.tasks_validated * 100.0 / NULLIF(p.total_tasks - p.tasks_bad_imagery, 0), 0 + ), 2) AS percent_validated, + ROUND(CAST(COALESCE( + ST_Area(p.geometry::geography) / 1000000, 0 + ) AS numeric), 3) AS total_area, + p.country, + p.created AS creation_date + FROM projects p + LEFT JOIN organisations o ON o.id = p.organisation_id + LEFT JOIN users u ON u.id = p.author_id + WHERE p.geometry IS NOT NULL + """ - # Get public projects only for anonymous user. + else: + query = """ + SELECT + p.id AS id, + p.difficulty, + p.priority, + p.default_locale, + ST_AsGeoJSON(p.centroid) AS centroid, + p.organisation_id, + p.tasks_bad_imagery, + p.tasks_mapped, + p.tasks_validated, + p.status, + p.total_tasks, + p.last_updated, + p.due_date, + p.country, + p.mapping_types, + u.name AS author_name, + u.username AS author_username, + o.name AS organisation_name, + o.logo AS organisation_logo + FROM projects p + LEFT JOIN organisations o ON o.id = p.organisation_id + LEFT JOIN users u ON u.id = p.author_id + WHERE p.geometry IS NOT NULL + """ + + filters = [] + params = {} if user is None: - query = query.filter(Project.private.is_(False)) - - if user is not None and user.role != UserRole.ADMIN.value: - # Get also private projects of teams that the user is member. - project_ids = [[p.project_id for p in t.team.projects] for t in user.teams] - - # Get projects that belong to user organizations. - orgs_projects_ids = [[p.id for p in u.projects] for u in user.organisations] - - project_ids.extend(orgs_projects_ids) - - project_ids = tuple( - set([item for sublist in project_ids for item in sublist]) - ) - - query = query.filter( - or_(Project.private.is_(False), Project.id.in_(project_ids)) - ) + filters.append("p.private = :private") + params["private"] = False + if user is not None: + if user.role != UserRole.ADMIN.value: + # Fetch project_ids for user's teams + team_projects_query = """ + SELECT p.id + FROM projects p + JOIN project_teams pt ON pt.project_id = p.id + JOIN teams t ON t.id = pt.team_id + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.user_id = :user_id + """ + team_projects = await db.fetch_all( + team_projects_query, {"user_id": user.id} + ) + # Fetch project_ids for user's organisations + org_projects_query = """ + SELECT p.id + FROM projects p + JOIN organisations o ON o.id = p.organisation_id + JOIN organisation_managers om ON om.organisation_id = o.id + WHERE om.user_id = :user_id + """ + org_projects = await db.fetch_all( + org_projects_query, {"user_id": user.id} + ) - # If the user is admin, no filter. - return query + # Combine and deduplicate project IDs + project_ids = tuple( + set( + [row["id"] for row in team_projects] + + [row["id"] for row in org_projects] + ) + ) + if project_ids: + filters.append("p.private = :private OR p.id = ANY(:project_ids)") + params["private"] = False + params["project_ids"] = list(project_ids) + else: + filters.append("p.private = :private") + params["private"] = False + if filters: + query += " AND (" + " AND ".join(filters) + ")" + + return query, params @staticmethod - def create_result_dto( - project: Project, - preferred_locale: str, - total_contributors: int, - with_partner_names: bool = False, - with_author_name: bool = True, - ) -> ListSearchResultDTO: - project_info_dto = ProjectInfo.get_dto_for_locale( - project.id, preferred_locale, project.default_locale + async def create_result_dto( + project, preferred_locale, total_contributors, db: Database + ): + project_info_dto = await ProjectInfo.get_dto_for_locale( + db, project.id, preferred_locale, project.default_locale ) - project_obj = Project.get(project.id) + list_dto = ListSearchResultDTO() list_dto.project_id = project.id list_dto.locale = project_info_dto.locale @@ -181,105 +193,160 @@ def create_result_dto( list_dto.short_description = project_info_dto.short_description list_dto.last_updated = project.last_updated list_dto.due_date = project.due_date - list_dto.percent_mapped = project_obj.calculate_tasks_percent( + list_dto.percent_mapped = Project.calculate_tasks_percent( "mapped", + project.tasks_mapped, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, ) - list_dto.percent_validated = project_obj.calculate_tasks_percent( + list_dto.percent_validated = Project.calculate_tasks_percent( "validated", + project.tasks_mapped, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, ) list_dto.status = ProjectStatus(project.status).name - list_dto.active_mappers = Project.get_active_mappers(project.id) + list_dto.active_mappers = await Project.get_active_mappers(project.id, db) list_dto.total_contributors = total_contributors list_dto.country = project.country + list_dto.author = project.author_name or project.author_username list_dto.organisation_name = project.organisation_name list_dto.organisation_logo = project.organisation_logo - list_dto.campaigns = Project.get_project_campaigns(project.id) - - list_dto.creation_date = project_obj.created - - if with_author_name: - list_dto.author = project_obj.author.name or project_obj.author.username - - if with_partner_names: - list_dto.partner_names = list( - set( - map( - lambda p: Partner.get_by_id(p.partner_id).name, - project_obj.partnerships, - ) - ) - ) - - # Use postgis to compute the total area of the geometry in square kilometers - list_dto.total_area = project_obj.query.with_entities( - func.coalesce(func.sum(func.ST_Area(project_obj.geometry, True) / 1000000)) - ).scalar() - list_dto.total_area = round(list_dto.total_area, 3) - + list_dto.campaigns = await Project.get_project_campaigns(project.id, db) return list_dto @staticmethod - def get_total_contributions(paginated_results): - paginated_projects_ids = [p.id for p in paginated_results] + async def get_total_contributions( + project_ids: List[int], db: Database + ) -> List[int]: + """Fetch total contributions for given project IDs.""" + if not project_ids: + return [] + + query = """ + SELECT + p.id AS id, + COUNT(DISTINCT th.user_id) AS total + FROM projects p + LEFT JOIN task_history th ON th.project_id = p.id + AND th.action != 'COMMENT' + WHERE p.id = ANY(:project_ids) + GROUP BY p.id + """ + + params = {"project_ids": project_ids} + + result = await db.fetch_all(query, params) + + return [row["total"] for row in result] - # We need to make a join to return projects without contributors. - project_contributors_count = ( - Project.query.with_entities( - Project.id, func.count(distinct(TaskHistory.user_id)).label("total") - ) - .filter(Project.id.in_(paginated_projects_ids)) - .outerjoin( - TaskHistory, - and_( - TaskHistory.project_id == Project.id, - TaskHistory.action != "COMMENT", - ), + @staticmethod + async def get_managed_projects(user_id, db): + org_projects_query = """ + SELECT p.id AS id + FROM projects p + JOIN organisations o ON o.id = p.organisation_id + JOIN organisation_managers om ON om.organisation_id = o.id + WHERE om.user_id = :user_id + """ + orgs_projects_ids = await db.fetch_all(org_projects_query, {"user_id": user_id}) + + team_projects_query = """ + SELECT pt.project_id AS id + FROM project_teams pt + JOIN team_members tm ON tm.team_id = pt.team_id + WHERE tm.user_id = :user_id + AND pt.role = :project_manager_role + AND tm.active = TRUE + """ + team_project_ids = await db.fetch_all( + team_projects_query, + { + "user_id": user_id, + "project_manager_role": TeamRoles.PROJECT_MANAGER.value, + }, + ) + project_ids = tuple( + set( + [row["id"] for row in orgs_projects_ids] + + [row["id"] for row in team_project_ids] ) - .group_by(Project.id) - .all() ) + return project_ids - return [p.total for p in project_contributors_count] + def csv_cache_key_builder(func, *args, **kwargs): + args_without_db = args[:-2] + return f"{func.__name__}:{args_without_db}:{kwargs}" @staticmethod - @cached(csv_download_cache) - def search_projects_as_csv(search_dto: ProjectSearchDTO, user) -> str: - all_results, _ = ProjectSearchService._filter_projects(search_dto, user, True) - rows = [row._asdict() for row in all_results] + @cached(cache=Cache.MEMORY, key_builder=csv_cache_key_builder, ttl=3600) + async def search_projects_as_csv( + search_dto: ProjectSearchDTO, user, db: Database, as_csv: bool = False + ) -> str: + if user: + user = await UserService.get_user_by_id(user, db) + all_results = await ProjectSearchService._filter_projects( + search_dto, user, db, as_csv + ) + rows = [dict(row) for row in all_results] is_user_admin = user is not None and user.role == UserRole.ADMIN.value - for row in rows: row["priority"] = ProjectPriority(row["priority"]).name row["difficulty"] = ProjectDifficulty(row["difficulty"]).name row["status"] = ProjectStatus(row["status"]).name row["total_area"] = round(row["total_area"], 3) - row["total_contributors"] = Project.get_project_total_contributions( - row["id"] + row["total_contributors"] = await Project.get_project_total_contributions( + row["id"], db ) row["author"] = row["author_name"] or row["author_username"] + project_name_query = """ + SELECT COALESCE( + (SELECT pi.name + FROM project_info pi + WHERE pi.project_id = :project_id + AND pi.locale = :locale + LIMIT 1), + (SELECT pi.name + FROM project_info pi + WHERE pi.project_id = :project_id + AND pi.locale = 'en' + LIMIT 1) + ) AS name + """ + + result = await db.fetch_one( + project_name_query, + { + "project_id": row["id"], + "locale": search_dto.preferred_locale or "en", + }, + ) + + row["project_name"] = result["name"] if result else None + if is_user_admin: - partners_names = ( - ProjectPartnership.query.with_entities( - ProjectPartnership.project_id, Partner.name - ) - .join(Partner, ProjectPartnership.partner_id == Partner.id) - .filter(ProjectPartnership.project_id == row["id"]) - .group_by(ProjectPartnership.project_id, Partner.name) - .all() + query = """ + SELECT p.name + FROM project_partnerships pp + JOIN partners p ON pp.partner_id = p.id + WHERE pp.project_id = :project_id + GROUP BY pp.project_id, p.name + """ + partners_names = await db.fetch_all( + query=query, values={"project_id": row["id"]} ) - row["partner_names"] = [pn for (_, pn) in partners_names] + row["partner_names"] = [record["name"] for record in partners_names] df = pd.json_normalize(rows) columns_to_drop = [ "default_locale", - "organisation_id", - "organisation_logo", "tasks_bad_imagery", "tasks_mapped", "tasks_validated", "total_tasks", - "centroid", "author_name", "author_username", ] @@ -294,6 +361,7 @@ def search_projects_as_csv(search_dto: ProjectSearchDTO, user) -> str: "total_area": "totalArea", "total_contributors": "totalContributors", "partner_names": "partnerNames", + "author_name": "author", "project_name": "name", } @@ -303,32 +371,38 @@ def search_projects_as_csv(search_dto: ProjectSearchDTO, user) -> str: axis=1, ) df.rename(columns=colummns_to_rename, inplace=True) + cols_order = ["projectId", "name"] + [ + col for col in df.columns if col not in ["projectId", "name"] + ] + df = df[cols_order] return df.to_csv(index=False) @staticmethod - @cached(search_cache) - def search_projects(search_dto: ProjectSearchDTO, user) -> ProjectSearchResultsDTO: + # @cached(cache=Cache.MEMORY, key_builder=cache_key_builder, ttl=300) + async def search_projects( + search_dto: ProjectSearchDTO, user, db + ) -> ProjectSearchResultsDTO: """Searches all projects for matches to the criteria provided by the user""" - all_results, paginated_results = ProjectSearchService._filter_projects( - search_dto, user - ) - if paginated_results.total == 0: + ( + all_results, + paginated_results, + pagination_dto, + ) = await ProjectSearchService._filter_projects(search_dto, user, db) + if pagination_dto.total == 0: raise NotFound(sub_code="PROJECTS_NOT_FOUND") dto = ProjectSearchResultsDTO() dto.results = [ - ProjectSearchService.create_result_dto( + await ProjectSearchService.create_result_dto( p, search_dto.preferred_locale, - Project.get_project_total_contributions(p[0]), - with_partner_names=( - user is not None and user.role == UserRole.ADMIN.value - ), - with_author_name=True, + await Project.get_project_total_contributions(p.id, db), + db, ) - for p in paginated_results.items + for p in paginated_results ] - dto.pagination = Pagination(paginated_results) + + dto.pagination = pagination_dto if search_dto.omit_map_results: return dto @@ -346,329 +420,451 @@ def search_projects(search_dto: ProjectSearchDTO, user) -> ProjectSearchResultsD features.append(feature) feature_collection = geojson.FeatureCollection(features) dto.map_results = feature_collection - return dto - @staticmethod - def _filter_projects(search_dto: ProjectSearchDTO, user, as_csv=False): - """Filters all projects based on criteria provided by user""" - - query = ProjectSearchService.create_search_query(user, as_csv) - - query = query.join(ProjectInfo).filter( - ProjectInfo.locale.in_([search_dto.preferred_locale, "en"]) + async def _filter_projects( + search_dto: ProjectSearchDTO, user, db: Database, as_csv: bool = False + ): + base_query, params = await ProjectSearchService.create_search_query( + db, user, as_csv ) - project_status_array = [] + # Initialize filter list and parameters dictionary + filters = [] + if search_dto.preferred_locale or search_dto.text_search: + subquery_filters = [] + if search_dto.preferred_locale: + subquery_filters.append("locale IN (:preferred_locale, 'en')") + params["preferred_locale"] = search_dto.preferred_locale + + if search_dto.text_search: + search_text = "".join( + char for char in search_dto.text_search if char not in "@|&!><\\():" + ) + tsquery_search = " & ".join([x for x in search_text.split(" ") if x]) + ilike_search = f"%{search_text}%" + + subquery_filters.append( + """ + text_searchable @@ to_tsquery('english', :tsquery_search) + OR name ILIKE :text_search + """ + ) + params["tsquery_search"] = tsquery_search + params["text_search"] = ilike_search + + filters.append( + """ + p.id = ANY( + SELECT project_id + FROM project_info + WHERE {} + ) + """.format( + " AND ".join(subquery_filters) + ) + ) + if search_dto.project_statuses: - project_status_array = [ - ProjectStatus[project_status].value - for project_status in search_dto.project_statuses + statuses = [ + ProjectStatus[status].value for status in search_dto.project_statuses ] - query = query.filter(Project.status.in_(project_status_array)) + if not user: + # Remove DRAFT for non-logged-in users + statuses_without_draft = [ + s for s in statuses if s != ProjectStatus.DRAFT.value + ] + if statuses_without_draft: + filters.append("p.status = ANY(:statuses_without_draft)") + params["statuses_without_draft"] = tuple(statuses_without_draft) + else: + # If only DRAFT was specified, show no projects + filters.append("FALSE") + elif user.role == UserRole.ADMIN.value: + # Admins see all projects with specified statuses + filters.append("p.status = ANY(:statuses)") + params["statuses"] = tuple(statuses) + else: + draft_status = ProjectStatus.DRAFT.value + if draft_status in statuses: + managed_projects = await ProjectSearchService.get_managed_projects( + user.id, db + ) + if managed_projects: + # Allow all statuses, but restrict DRAFT to managed projects only. + filters.append( + ( + "p.status = ANY(:statuses) " + "AND (p.status != :draft_status " + "OR p.id = ANY(:managed_projects) " + "OR p.author_id = :author_id)" + ) + ) + params["statuses"] = tuple(statuses) + params["draft_status"] = draft_status + params["managed_projects"] = managed_projects + params["author_id"] = user.id + else: + # If no managed projects, exclude DRAFT + filters.append( + "p.status = ANY(:statuses) AND p.status != :draft_status" + ) + params["statuses"] = tuple(statuses) + params["draft_status"] = draft_status + else: + # If DRAFT not in statuses, apply filter normally + filters.append("p.status = ANY(:statuses)") + params["statuses"] = tuple(statuses) else: + # Default to published if no statuses and no created_by if not search_dto.created_by: - project_status_array = [ProjectStatus.PUBLISHED.value] - query = query.filter(Project.status.in_(project_status_array)) + filters.append("p.status = :published_status") + params["published_status"] = ProjectStatus.PUBLISHED.value if not search_dto.based_on_user_interests: - # Only filter by interests if not based on user interests is provided if search_dto.interests: - query = query.join( - project_interests, project_interests.c.project_id == Project.id - ).filter(project_interests.c.interest_id.in_(search_dto.interests)) - else: - user = UserService.get_user_by_id(search_dto.based_on_user_interests) - query = query.join( - project_interests, project_interests.c.project_id == Project.id - ).filter( - project_interests.c.interest_id.in_( - [interest.id for interest in user.interests] + filters.append( + "p.id IN (SELECT project_id FROM project_interests WHERE interest_id = ANY(:interests))" ) + params["interests"] = tuple(search_dto.interests) + else: + user_interest_query = """ + SELECT interest_id + FROM user_interests + WHERE user_id = :user_id + """ + results = await db.fetch_all( + query=user_interest_query, values={"user_id": user.id} ) + user_interests = ( + [record["interest_id"] for record in results] if results else [] + ) + filters.append( + "p.id IN (SELECT project_id FROM project_interests WHERE interest_id = ANY(:user_interests))" + ) + params["user_interests"] = tuple(user_interests) + if search_dto.created_by: - query = query.filter(Project.author_id == search_dto.created_by) + filters.append("p.author_id = :created_by") + params["created_by"] = search_dto.created_by + if search_dto.mapped_by: - projects_mapped = UserService.get_projects_mapped(search_dto.mapped_by) - query = query.filter(Project.id.in_(projects_mapped)) - if search_dto.favorited_by: - projects_favorited = user.favorites - query = query.filter( - Project.id.in_([project.id for project in projects_favorited]) + mapped_projects = await UserService.get_projects_mapped( + search_dto.mapped_by, db ) + filters.append("p.id = ANY(:mapped_projects)") + params["mapped_projects"] = tuple(mapped_projects) + + if search_dto.favorited_by: + favorited_projects = [] + if user: + query = """ + SELECT project_id + FROM project_favorites + WHERE user_id = :user_id + """ + results = await db.fetch_all(query=query, values={"user_id": user.id}) + favorited_projects = [record["project_id"] for record in results] + filters.append("p.id = ANY(:favorited_projects)") + params["favorited_projects"] = tuple(favorited_projects) + if search_dto.difficulty and search_dto.difficulty.upper() != "ALL": - query = query.filter( - Project.difficulty == ProjectDifficulty[search_dto.difficulty].value - ) + filters.append("p.difficulty = :difficulty") + params["difficulty"] = ProjectDifficulty[search_dto.difficulty].value + if search_dto.action and search_dto.action != "any": if search_dto.action == "map": - query = ProjectSearchService.filter_projects_to_map(query, user) - if search_dto.action == "validate": - query = ProjectSearchService.filter_projects_to_validate(query, user) + mapping_project_ids = await ProjectSearchService.filter_projects_to_map( + user, db + ) + filters.append("p.id = ANY(:mapping_project_ids)") + params["mapping_project_ids"] = tuple(mapping_project_ids) + + elif search_dto.action == "validate": + validation_project_ids = ( + await ProjectSearchService.filter_projects_to_validate(user, db) + ) + filters.append("p.id = ANY(:validation_project_ids)") + params["validation_project_ids"] = tuple(validation_project_ids) if search_dto.organisation_name: - query = query.filter(Organisation.name == search_dto.organisation_name) + filters.append("o.name = :organisation_name") + params["organisation_name"] = search_dto.organisation_name if search_dto.organisation_id: - query = query.filter(Organisation.id == search_dto.organisation_id) + filters.append("o.id = :organisation_id") + params["organisation_id"] = int(search_dto.organisation_id) if search_dto.team_id: - query = query.join( - ProjectTeams, ProjectTeams.project_id == Project.id - ).filter(ProjectTeams.team_id == search_dto.team_id) + filters.append( + "p.id IN (SELECT project_id FROM project_teams WHERE team_id = :team_id)" + ) + params["team_id"] = int(search_dto.team_id) if search_dto.campaign: - query = query.join(Campaign, Project.campaign).group_by(Campaign.name) - query = query.filter(Campaign.name == search_dto.campaign) + filters.append( + "p.id IN (SELECT cp.project_id FROM campaign_projects cp " + "JOIN campaigns c ON c.id = cp.campaign_id WHERE c.name = :campaign_name)" + ) + params["campaign_name"] = search_dto.campaign if search_dto.mapping_types: - # Construct array of mapping types for query - mapping_type_array = [] - if search_dto.mapping_types_exact: - mapping_type_array = [ - { - MappingTypes[mapping_type].value - for mapping_type in search_dto.mapping_types - } - ] - query = query.filter(Project.mapping_types.in_(mapping_type_array)) + filters.append( + "p.mapping_types @> :mapping_types AND array_length(p.mapping_types, 1) = :mapping_length" + ) + params["mapping_types"] = tuple( + MappingTypes[mapping_type].value + for mapping_type in search_dto.mapping_types + ) + params["mapping_length"] = len(search_dto.mapping_types) else: - mapping_type_array = [ + filters.append("p.mapping_types && :mapping_types") + params["mapping_types"] = tuple( MappingTypes[mapping_type].value for mapping_type in search_dto.mapping_types - ] - query = query.filter(Project.mapping_types.overlap(mapping_type_array)) - - if search_dto.text_search: - # We construct an OR search, so any projects that contain or more of the search terms should be returned - invalid_ts_chars = "@|&!><\\():" - search_text = "".join( - char for char in search_dto.text_search if char not in invalid_ts_chars - ) - or_search = " | ".join([x for x in search_text.split(" ") if x != ""]) - opts = [ - ProjectInfo.text_searchable.match( - or_search, postgresql_regconfig="english" - ), - ProjectInfo.name.ilike(f"%{or_search}%"), - ] - try: - opts.append(Project.id == int(search_dto.text_search)) - except ValueError: - pass - - query = query.filter(or_(*opts)) + ) if search_dto.country: - # Unnest country column array. - sq = Project.query.with_entities( - Project.id, func.unnest(Project.country).label("country") - ).subquery() - query = query.filter( - func.lower(sq.c.country) == search_dto.country.lower() - ).filter(Project.id == sq.c.id) + filters.append( + "LOWER(:country) = ANY(ARRAY(SELECT LOWER(c) FROM unnest(p.country) AS c))" + ) + params["country"] = search_dto.country.lower() if search_dto.last_updated_gte: - last_updated_gte = validate_date_input(search_dto.last_updated_gte) - query = query.filter(Project.last_updated >= last_updated_gte) + filters.append("p.last_updated >= :last_updated_gte") + params["last_updated_gte"] = validate_date_input( + search_dto.last_updated_gte + ) if search_dto.last_updated_lte: - last_updated_lte = validate_date_input(search_dto.last_updated_lte) - query = query.filter(Project.last_updated <= last_updated_lte) + filters.append("p.last_updated <= :last_updated_lte") + params["last_updated_lte"] = validate_date_input( + search_dto.last_updated_lte + ) if search_dto.created_gte: - created_gte = validate_date_input(search_dto.created_gte) - query = query.filter(Project.created >= created_gte) + filters.append("p.created >= :created_gte") + params["created_gte"] = validate_date_input(search_dto.created_gte) if search_dto.created_lte: - created_lte = validate_date_input(search_dto.created_lte) - query = query.filter(Project.created <= created_lte) + filters.append("p.created <= :created_lte") + params["created_lte"] = validate_date_input(search_dto.created_lte) if search_dto.partner_id: - query = query.join( - ProjectPartnership, ProjectPartnership.project_id == Project.id - ).filter(ProjectPartnership.partner_id == search_dto.partner_id) + partner_conditions = ["pp.partner_id = :partner_id"] + params["partner_id"] = int(search_dto.partner_id) if search_dto.partnership_from: partnership_from = validate_date_input(search_dto.partnership_from) - query = query.filter(ProjectPartnership.started_on <= partnership_from) + partner_conditions.append("pp.started_on <= :partnership_from") + params["partnership_from"] = partnership_from if search_dto.partnership_to: partnership_to = validate_date_input(search_dto.partnership_to) - query = query.filter( - (ProjectPartnership.ended_on.is_(None)) - | (ProjectPartnership.ended_on >= partnership_to) + partner_conditions.append( + "(pp.ended_on IS NULL OR pp.ended_on >= :partnership_to)" + ) + params["partnership_to"] = partnership_to + + filters.append( + """ + p.id = ANY( + SELECT pp.project_id + FROM project_partnerships pp + WHERE {} + ) + """.format( + " AND ".join(partner_conditions) ) + ) - order_by = search_dto.order_by + if search_dto.managed_by and user.role != UserRole.ADMIN.value: + project_ids = await ProjectSearchService.get_managed_projects(user.id, db) + if project_ids: + filters.append("p.id = ANY(:managed_projects)") + params["managed_projects"] = project_ids + + order_by_clause = "" + + if search_dto.order_by: + order_by = search_dto.order_by + + if order_by == "percent_mapped": + percent_mapped_sql = """ + CASE + WHEN (p.total_tasks - p.tasks_bad_imagery) = 0 THEN 0 + ELSE (p.tasks_mapped + p.tasks_validated) * 100 + / (p.total_tasks - p.tasks_bad_imagery) + END + """ + if search_dto.order_by_type == "DESC": + order_by_clause = f" ORDER BY {percent_mapped_sql} DESC" + else: + order_by_clause = f" ORDER BY {percent_mapped_sql} ASC" + + elif order_by == "percent_validated": + percent_validated_sql = """ + CASE + WHEN (p.total_tasks - p.tasks_bad_imagery) = 0 THEN 0 + ELSE p.tasks_validated * 100 + / (p.total_tasks - p.tasks_bad_imagery) + END + """ + if search_dto.order_by_type == "DESC": + order_by_clause = f" ORDER BY {percent_validated_sql} DESC" + else: + order_by_clause = f" ORDER BY {percent_validated_sql} ASC" - if search_dto.order_by == "percent_mapped": - if search_dto.order_by_type == "DESC": - order_by = Project.percent_mapped.desc() - else: - order_by = Project.percent_mapped.asc() - query = query.order_by(order_by) - elif search_dto.order_by == "percent_validated": - if search_dto.order_by_type == "DESC": - order_by = Project.percent_validated.desc() else: - order_by = Project.percent_validated.asc() - query = query.order_by(order_by) - else: - if search_dto.order_by_type == "DESC": - order_by = desc(search_dto.order_by) - query = query.order_by(order_by).distinct(search_dto.order_by, Project.id) + order_by = f"p.{order_by}" + if search_dto.order_by_type == "DESC": + order_by += " DESC" + order_by_clause = f" ORDER BY {order_by}" - if search_dto.managed_by and user.role != UserRole.ADMIN.value: - # Get all the projects associated with the user and team. - orgs_projects_ids = [[p.id for p in u.projects] for u in user.organisations] - orgs_projects_ids = [ - item for sublist in orgs_projects_ids for item in sublist - ] + if filters: + sql_query = base_query + " AND " + " AND ".join(filters) + else: + sql_query = base_query + sql_query += order_by_clause + all_results = await db.fetch_all(sql_query, values=params) + if as_csv: + return all_results + page = search_dto.page + per_page = 14 + offset = (page - 1) * per_page + sql_query_paginated = sql_query + f" LIMIT {per_page} OFFSET {offset}" + # Get total count + count_query = f"SELECT COUNT(*) FROM ({sql_query}) AS count_subquery" + total_count = await db.fetch_val(count_query, values=params) + paginated_results = await db.fetch_all(sql_query_paginated, values=params) + pagination_dto = Pagination.from_total_count(page, per_page, total_count) + return all_results, paginated_results, pagination_dto - team_project_ids = [ - [ - p.project_id - for p in u.team.projects - if p.role == TeamRoles.PROJECT_MANAGER.value - ] - for u in user.teams - ] - team_project_ids = [ - item for sublist in team_project_ids for item in sublist + @staticmethod + async def filter_by_user_permission(user, permission: str): + if not user or user.role == UserRole.ADMIN.value: + return "TRUE", {} # Admin can access all projects + + # Select permission class and team roles based on the permission type + if permission == "validation_permission": + permission_class = ValidationPermission + team_roles = [TeamRoles.VALIDATOR.value, TeamRoles.PROJECT_MANAGER.value] + else: + permission_class = MappingPermission + team_roles = [ + TeamRoles.MAPPER.value, + TeamRoles.VALIDATOR.value, + TeamRoles.PROJECT_MANAGER.value, ] - orgs_projects_ids.extend(team_project_ids) - ids = tuple(set(orgs_projects_ids)) - query = query.filter(Project.id.in_(ids)) - - all_results = [] - if not search_dto.omit_map_results: - query_result = query - query_result.column_descriptions.clear() - query_result.add_columns( - Project.id, - Project.centroid.ST_AsGeoJSON().label("centroid"), - Project.priority, + if user.mapping_level == MappingLevel.BEGINNER.value: + permission_condition = ( + f"AND p.{permission} = {permission_class.TEAMS.value}" ) - all_results = query_result.all() + else: + permission_condition = "" - paginated_results = query.paginate( - page=search_dto.page, per_page=14, error_out=True - ) + if user.mapping_level != MappingLevel.BEGINNER.value: + level_condition = f", {permission_class.LEVEL.value}" + else: + level_condition = "" + + condition = f""" + ( + p.id IN ( + SELECT DISTINCT pt.project_id + FROM team_members tm + JOIN project_teams pt ON tm.team_id = pt.team_id + WHERE tm.user_id = :user_id + AND tm.active = True + AND pt.role = ANY(:team_roles) + ) + {permission_condition} + ) + OR p.{permission} IN ( + {permission_class.ANY.value}{level_condition} + ) + """ - return all_results, paginated_results + params = {"user_id": user.id, "team_roles": team_roles} + return condition, params @staticmethod - def filter_by_user_permission(query, user, permission: str): - """Filter projects a user can map or validate, based on their permissions.""" - if user and user.role != UserRole.ADMIN.value: - if permission == "validation_permission": - permission_class = ValidationPermission - team_roles = [ - TeamRoles.VALIDATOR.value, - TeamRoles.PROJECT_MANAGER.value, - ] - else: - permission_class = MappingPermission - team_roles = [ - TeamRoles.MAPPER.value, - TeamRoles.VALIDATOR.value, - TeamRoles.PROJECT_MANAGER.value, - ] + async def filter_projects_to_map(user, db: Database): + """Filter projects needing mapping with proper permission checks""" + base_query = """ + SELECT p.id FROM projects p + WHERE (p.tasks_mapped + p.tasks_validated) < (p.total_tasks - p.tasks_bad_imagery) + AND p.status = :published_status + """ + params = {"published_status": ProjectStatus.PUBLISHED.value} - selection = [] - # get ids of projects assigned to the user's teams - [ - [ - selection.append(team_project.project_id) - for team_project in user_team.team.projects - if team_project.project_id not in selection - and team_project.role in team_roles - ] - for user_team in user.teams - ] - if user.mapping_level == MappingLevel.BEGINNER.value: - # if user is beginner, get only projects with ANY or TEAMS mapping permission - # in the later case, only those that are associated with user teams - query = query.filter( - or_( - and_( - Project.id.in_(selection), - getattr(Project, permission) - == permission_class.TEAMS.value, - ), - getattr(Project, permission) == permission_class.ANY.value, - ) - ) - else: - # if user is intermediate or advanced, get projects with ANY or LEVEL permission - # and projects associated with user teams - query = query.filter( - or_( - Project.id.in_(selection), - getattr(Project, permission).in_( - [ - permission_class.ANY.value, - permission_class.LEVEL.value, - ] - ), - ) - ) + if user and user.role != UserRole.ADMIN.value: + ( + perm_condition, + perm_params, + ) = await ProjectSearchService.filter_by_user_permission( + user, "mapping_permission" + ) + base_query += f" AND ({perm_condition})" + params.update(perm_params) - return query + records = await db.fetch_all(base_query, params) + return [r["id"] for r in records] if records else [] @staticmethod - def filter_projects_to_map(query, user): - """Filter projects that needs mapping and can be mapped by the current user.""" - query = query.filter( - Project.tasks_mapped + Project.tasks_validated - < Project.total_tasks - Project.tasks_bad_imagery - ) - return ProjectSearchService.filter_by_user_permission( - query, user, "mapping_permission" - ) + async def filter_projects_to_validate(user, db: Database): + """Filter projects needing validation with proper permission checks""" + base_query = """ + SELECT p.id FROM projects p + WHERE p.tasks_validated < (p.total_tasks - p.tasks_bad_imagery) + AND p.status = :published_status + """ + params = {"published_status": ProjectStatus.PUBLISHED.value} - @staticmethod - def filter_projects_to_validate(query, user): - """Filter projects that needs validation and can be validated by the current user.""" - query = query.filter( - Project.tasks_validated < Project.total_tasks - Project.tasks_bad_imagery - ) - return ProjectSearchService.filter_by_user_permission( - query, user, "validation_permission" - ) + if user and user.role != UserRole.ADMIN.value: + ( + perm_condition, + perm_params, + ) = await ProjectSearchService.filter_by_user_permission( + user, "validation_permission" + ) + base_query += f" AND ({perm_condition})" + params.update(perm_params) + + records = await db.fetch_all(base_query, params) + return [r["id"] for r in records] if records else [] @staticmethod - def get_projects_geojson( - search_bbox_dto: ProjectSearchBBoxDTO, + async def get_projects_geojson( + search_bbox_dto: ProjectSearchBBoxDTO, db: Database ) -> geojson.FeatureCollection: """Search for projects meeting the provided criteria. Returns a GeoJSON feature collection.""" - # make a polygon from provided bounding box - polygon = ProjectSearchService._make_4326_polygon_from_bbox( - search_bbox_dto.bbox, search_bbox_dto.input_srid + polygon = await ProjectSearchService._make_4326_polygon_from_bbox( + search_bbox_dto.bbox, search_bbox_dto.input_srid, db ) - # validate the bbox area is less than or equal to the max area allowed to prevent # abuse of the api or performance issues from large requests - if not ProjectSearchService.validate_bbox_area(polygon): - raise BBoxTooBigError( - "BBoxTooBigError- Requested bounding box is too large" + if not await ProjectSearchService.validate_bbox_area(polygon, db): + raise HTTPException( + status_code=400, + detail="Organisation has projects or teams, cannot be deleted.", ) - # get projects intersecting the polygon for created by the author_id - intersecting_projects = ProjectSearchService._get_intersecting_projects( - polygon, search_bbox_dto.project_author + intersecting_projects = await ProjectSearchService._get_intersecting_projects( + polygon, search_bbox_dto.project_author, db ) - # allow an empty feature collection to be returned if no intersecting features found, since this is primarily # for returning data to show on a map features = [] for project in intersecting_projects: try: - localDTO = ProjectInfo.get_dto_for_locale( - project.id, search_bbox_dto.preferred_locale, project.default_locale + localDTO = await ProjectInfo.get_dto_for_locale( + db, + project.id, + search_bbox_dto.preferred_locale, + project.default_locale, ) except Exception: pass @@ -686,57 +882,88 @@ def get_projects_geojson( return geojson.FeatureCollection(features) @staticmethod - def _get_intersecting_projects(search_polygon: Polygon, author_id: int): + async def _get_intersecting_projects( + search_polygon: Polygon, author_id: int, db: Database + ): """Executes a database query to get the intersecting projects created by the author if provided""" + try: + # Convert the search_polygon bounds to a bounding box (WKT) + bounds = search_polygon.bounds + envelope_wkt = f"ST_MakeEnvelope({bounds[0]}, {bounds[1]}, {bounds[2]}, {bounds[3]}, 4326)" + + # Base SQL query with parameter placeholders + query_str = f""" + SELECT + id, + status, + default_locale, + ST_AsGeoJSON(geometry) AS geometry + FROM + projects + WHERE + ST_Intersects(geometry, {envelope_wkt}) + """ + + # If an author_id is provided, append the AND condition + if author_id: + query_str += " AND author_id = :author_id" + + # Execute the query asynchronously with the parameters + values = {"author_id": author_id} if author_id else {} + results = await db.fetch_all(query=query_str, values=values) + + return results - query = db.session.query( - Project.id, - Project.status, - Project.default_locale, - Project.geometry.ST_AsGeoJSON().label("geometry"), - ).filter( - ST_Intersects( - Project.geometry, - ST_MakeEnvelope( - search_polygon.bounds[0], - search_polygon.bounds[1], - search_polygon.bounds[2], - search_polygon.bounds[3], - 4326, - ), + except Exception as e: + logger.error(f"Error fetching intersecting projects: {e}") + raise ProjectSearchServiceError( + f"Error fetching intersecting projects: {e}" ) - ) - - if author_id: - query = query.filter(Project.author_id == author_id) - - return query.all() @staticmethod - def _make_4326_polygon_from_bbox(bbox: list, srid: int) -> Polygon: - """make a shapely Polygon in SRID 4326 from bbox and srid""" + async def _make_4326_polygon_from_bbox( + bbox: list, srid: int, db: Database + ) -> Polygon: + """Make a shapely Polygon in SRID 4326 from bbox and srid""" try: polygon = box(bbox[0], bbox[1], bbox[2], bbox[3]) - if not srid == 4326: + + # If the SRID is not 4326, transform it to 4326 + if srid != 4326: geometry = shape.from_shape(polygon, srid) - with db.engine.connect() as conn: - geom_4326 = conn.execute(ST_Transform(geometry, 4326)).scalar() + # Construct the raw SQL query to transform the geometry + query = "SELECT ST_Transform(ST_GeomFromText(:wkt, :srid), 4326) AS geom_4326" + values = {"wkt": geometry.wkt, "srid": srid} + + # Execute the SQL query using the encode databases instance + result = await db.fetch_one(query=query, values=values) + geom_4326 = result["geom_4326"] polygon = shape.to_shape(geom_4326) + except Exception as e: - current_app.logger.error(f"InvalidData- error making polygon: {e}") + logger.error(f"InvalidData- error making polygon: {e}") raise ProjectSearchServiceError(f"InvalidData- error making polygon: {e}") + return polygon @staticmethod - def _get_area_sqm(polygon: Polygon) -> float: - """get the area of the polygon in square metres""" - with db.engine.connect() as conn: - return conn.execute( - ST_Area(ST_Transform(shape.from_shape(polygon, 4326), 3857)) - ).scalar() + async def _get_area_sqm(polygon: Polygon, db: Database) -> float: + """Get the area of the polygon in square meters.""" + try: + geometry_wkt = polygon.wkt + + query = "SELECT ST_Area(ST_Transform(ST_GeomFromText(:wkt, 4326), 3857)) AS area" + values = {"wkt": geometry_wkt} + + result = await db.fetch_one(query=query, values=values) + return result["area"] + + except Exception as e: + logger.error(f"Error calculating area: {e}") + raise ProjectSearchServiceError(f"Error calculating area: {e}") @staticmethod - def validate_bbox_area(polygon: Polygon) -> bool: - """check polygon does not exceed maximim allowed area""" - area = ProjectSearchService._get_area_sqm(polygon) + async def validate_bbox_area(polygon: Polygon, db: Database) -> bool: + """Check if the polygon does not exceed the maximum allowed area.""" + area = await ProjectSearchService._get_area_sqm(polygon, db) return area <= MAX_AREA diff --git a/backend/services/project_service.py b/backend/services/project_service.py index 810d4c7edb..227761abc9 100644 --- a/backend/services/project_service.py +++ b/backend/services/project_service.py @@ -1,68 +1,80 @@ -import threading -from cachetools import TTLCache, cached -from flask import current_app +import json +from datetime import datetime, timedelta, timezone + import geojson -from datetime import datetime, timedelta +from aiocache import Cache, cached -from backend.exceptions import NotFound +from databases import Database +from fastapi import HTTPException +from loguru import logger +from backend.config import get_settings +from backend.db import db_connection +from backend.exceptions import NotFound from backend.models.dtos.mapping_dto import TaskDTOs from backend.models.dtos.project_dto import ( + ProjectContribDTO, + ProjectContribsDTO, ProjectDTO, - ProjectSummary, + ProjectSearchResultsDTO, ProjectStatsDTO, + ProjectSummary, ProjectUserStatsDTO, - ProjectContribsDTO, - ProjectContribDTO, - ProjectSearchResultsDTO, ) -from backend.models.postgis.project_chat import ProjectChat from backend.models.postgis.organisation import Organisation -from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.project import Project, ProjectStatus from backend.models.postgis.statuses import ( + EncouragingEmailType, + MappingLevel, MappingNotAllowed, - ValidatingNotAllowed, MappingPermission, - ValidationPermission, TeamRoles, - EncouragingEmailType, - MappingLevel, + ValidatingNotAllowed, + ValidationPermission, ) -from backend.models.postgis.task import Task, TaskHistory +from backend.models.postgis.task import Task from backend.services.messaging.smtp_service import SMTPService -from backend.services.users.user_service import UserService -from backend.services.project_search_service import ProjectSearchService from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_search_service import ProjectSearchService from backend.services.team_service import TeamService -from sqlalchemy import func, or_ -from sqlalchemy.sql.expression import true - -summary_cache = TTLCache(maxsize=1024, ttl=600) +from backend.services.users.user_service import UserService class ProjectServiceError(Exception): """Custom Exception to notify callers an error occurred when handling projects""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectService: @staticmethod - def get_project_by_id(project_id: int) -> Project: - project = Project.get(project_id) - if project is None: - raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) + async def get_project_by_id(project_id: int, db: Database): + query = """ + SELECT * FROM projects WHERE id = :project_id + """ + project = await db.fetch_one(query=query, values={"project_id": project_id}) + + if not project: + raise HTTPException(status_code=404, detail="Project not found") return project @staticmethod - def exists(project_id: int) -> bool: - project = Project.exists(project_id) - if project is None: + async def exists(project_id: int, db: Database) -> bool: + # Query to check if the project exists + query = """ + SELECT 1 + FROM projects + WHERE id = :project_id + """ + + # Execute the query + result = await db.fetch_one(query=query, values={"project_id": project_id}) + + if result is None: raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) + return True @staticmethod @@ -74,8 +86,8 @@ def get_project_by_name(project_id: int) -> Project: return project @staticmethod - def auto_unlock_tasks(project_id: int): - Task.auto_unlock_tasks(project_id) + async def auto_unlock_tasks(project_id: int, db: Database): + await Task.auto_unlock_tasks(project_id, db) @staticmethod def delete_tasks(project_id: int, tasks_ids): @@ -95,63 +107,47 @@ def delete_tasks(project_id: int, tasks_ids): [t["obj"].delete() for t in tasks] @staticmethod - def get_contribs_by_day(project_id: int) -> ProjectContribsDTO: - # Validate that project exists - project = ProjectService.get_project_by_id(project_id) - - # Fetch all state change with date and task ID - stats = ( - TaskHistory.query.with_entities( - TaskHistory.action_text.label("action_text"), - func.DATE(TaskHistory.action_date).label("day"), - TaskHistory.task_id.label("task_id"), - ) - .filter(TaskHistory.project_id == project_id) - .filter( - TaskHistory.action == "STATE_CHANGE", - or_( - TaskHistory.action_text == "MAPPED", - TaskHistory.action_text == "VALIDATED", - TaskHistory.action_text == "INVALIDATED", - ), - ) - .group_by("action_text", "day", "task_id") - .order_by("day") - ).all() + async def get_contribs_by_day(project_id: int, db: Database) -> ProjectContribsDTO: + project = await ProjectService.get_project_by_id(project_id, db) + query = """ + SELECT + action_text, + DATE(action_date) AS day, + task_id + FROM task_history + WHERE project_id = :project_id + AND action = 'STATE_CHANGE' + AND action_text IN ('MAPPED', 'VALIDATED', 'INVALIDATED') + GROUP BY action_text, day, task_id + ORDER BY day ASC + """ + rows = await db.fetch_all(query=query, values={"project_id": project_id}) contribs_dto = ProjectContribsDTO() - # Filter and store unique dates - dates = list(set(r[1] for r in stats)) - dates.sort( - reverse=False - ) # Why was this reversed? To have the dates in ascending order - dates_list = [] + dates = sorted({row["day"] for row in rows}) + cumulative_mapped = 0 cumulative_validated = 0 - # A hashmap to track task state change updates tasks = { "MAPPED": {"total": 0}, "VALIDATED": {"total": 0}, "INVALIDATED": {"total": 0}, } + dates_list = [] for date in dates: dto = ProjectContribDTO( - { - "date": date, - "mapped": 0, - "validated": 0, - "total_tasks": project.total_tasks, - } + date=date, mapped=0, validated=0, total_tasks=project.total_tasks ) - # s -> ('LOCKED_FOR_MAPPING', datetime.date(2019, 4, 23), 1) - # s[0] -> action, s[1] -> date, s[2] -> task_id - values = [(s[0], s[2]) for s in stats if date == s[1]] - values.sort(reverse=True) # Most recent action comes first - for val in values: - task_id = val[1] - task_status = val[0] + values = [ + (row["action_text"], row["task_id"]) + for row in rows + if row["day"] == date + ] + values.sort(reverse=True) + + for task_status, task_id in values: if task_status == "MAPPED": if task_id not in tasks["MAPPED"]: tasks["MAPPED"][task_id] = 1 @@ -169,7 +165,7 @@ def get_contribs_by_day(project_id: int) -> ProjectContribsDTO: tasks["MAPPED"][task_id] = 1 tasks["MAPPED"]["total"] += 1 dto.mapped += 1 - else: + else: # "INVALIDATED" if task_id not in tasks["INVALIDATED"]: tasks["INVALIDATED"][task_id] = 1 tasks["INVALIDATED"]["total"] += 1 @@ -188,15 +184,15 @@ def get_contribs_by_day(project_id: int) -> ProjectContribsDTO: cumulative_validated = tasks["VALIDATED"]["total"] dto.cumulative_mapped = cumulative_mapped dto.cumulative_validated = cumulative_validated + dates_list.append(dto) contribs_dto.stats = dates_list - return contribs_dto @staticmethod - def get_project_dto_for_mapper( - project_id, current_user_id, locale="en", abbrev=False + async def get_project_dto_for_mapper( + project_id, current_user_id, db: Database, locale="en", abbrev=False ) -> ProjectDTO: """ Get the project DTO for mappers @@ -204,10 +200,12 @@ def get_project_dto_for_mapper( :param locale: Locale the mapper has requested :raises ProjectServiceError, NotFound """ - project = ProjectService.get_project_by_id(project_id) + project = await ProjectService.get_project_by_id(project_id, db) # if project is public and is not draft, we don't need to check permissions if not project.private and not project.status == ProjectStatus.DRAFT.value: - return project.as_dto_for_mapping(current_user_id, locale, abbrev) + return await Project.as_dto_for_mapping( + project.id, db, current_user_id, locale, abbrev + ) is_allowed_user = True is_team_member = None @@ -215,8 +213,8 @@ def get_project_dto_for_mapper( if current_user_id: is_manager_permission = ( - ProjectAdminService.is_user_action_permitted_on_project( - current_user_id, project_id + await ProjectAdminService.is_user_action_permitted_on_project( + current_user_id, project_id, db ) ) # Draft Projects - admins, authors, org admins & team managers permitted @@ -231,16 +229,17 @@ def get_project_dto_for_mapper( if project.private and not is_manager_permission: is_allowed_user = False if current_user_id: - is_allowed_user = ( - len( - [ - user - for user in project.allowed_users - if user.id == current_user_id - ] - ) - > 0 + # Query to check if the current user is an allowed user for the project + allowed_user_check_query = """ + SELECT 1 + FROM project_allowed_users pau + WHERE pau.project_id = :project_id AND pau.user_id = :user_id + """ + result = await db.fetch_one( + allowed_user_check_query, + {"project_id": project.id, "user_id": current_user_id}, ) + is_allowed_user = result is not None if not (is_allowed_user or is_manager_permission): if current_user_id: @@ -249,59 +248,75 @@ def get_project_dto_for_mapper( TeamRoles.VALIDATOR.value, TeamRoles.PROJECT_MANAGER.value, ] - is_team_member = TeamService.check_team_membership( - project_id, allowed_roles, current_user_id + is_team_member = await TeamService.check_team_membership( + project.id, allowed_roles, current_user_id, db ) if is_allowed_user or is_manager_permission or is_team_member: - return project.as_dto_for_mapping(current_user_id, locale, abbrev) + return await Project.as_dto_for_mapping( + project.id, db, current_user_id, locale, abbrev + ) else: return None @staticmethod - def get_project_tasks( - project_id, + async def get_project_tasks( + db: Database, + project_id: int, task_ids_str: str, order_by: str = None, order_by_type: str = "ASC", status: int = None, ): - project = ProjectService.get_project_by_id(project_id) - return project.tasks_as_geojson(task_ids_str, order_by, order_by_type, status) + await Project.exists(project_id, db) + return await Project.tasks_as_geojson( + db, project_id, task_ids_str, order_by, order_by_type, status + ) @staticmethod - def get_project_aoi(project_id): - project = ProjectService.get_project_by_id(project_id) - return project.get_aoi_geometry_as_geojson() + async def get_project_aoi(project_id, db: Database): + await Project.exists(project_id, db) + return await Project.get_aoi_geometry_as_geojson(project_id, db) @staticmethod - def get_project_priority_areas(project_id): - project = ProjectService.get_project_by_id(project_id) - geojson_areas = [] - for priority_area in project.priority_areas: - geojson_areas.append(priority_area.get_as_geojson()) + async def get_project_priority_areas(project_id: int, db: Database) -> list: + await Project.exists(project_id, db) + + # Fetch the priority areas' geometries as GeoJSON + query = """ + SELECT ST_AsGeoJSON(pa.geometry) AS geojson + FROM priority_areas pa + JOIN project_priority_areas ppa ON pa.id = ppa.priority_area_id + WHERE ppa.project_id = :project_id; + """ + rows = await db.fetch_all(query, values={"project_id": project_id}) + geojson_areas = [json.loads(row["geojson"]) for row in rows] if rows else [] return geojson_areas @staticmethod - def get_task_for_logged_in_user(user_id: int): + async def get_task_for_logged_in_user(user_id: int, db: Database): """if the user is working on a task in the project return it""" - tasks = Task.get_locked_tasks_for_user(user_id) + tasks = await Task.get_locked_tasks_for_user(user_id, db) - tasks_dto = tasks - return tasks_dto + return tasks @staticmethod - def get_task_details_for_logged_in_user(user_id: int, preferred_locale: str): + async def get_task_details_for_logged_in_user( + user_id: int, preferred_locale: str, db: Database + ): """if the user is working on a task in the project return it""" - tasks = Task.get_locked_tasks_details_for_user(user_id) + tasks = await Task.get_locked_tasks_details_for_user(user_id, db) if len(tasks) == 0: raise NotFound(sub_code="TASK_NOT_FOUND") - # TODO put the task details in to a DTO dtos = [] for task in tasks: - dtos.append(task.as_dto_with_instructions(preferred_locale)) + dtos.append( + await Task.as_dto_with_instructions( + task.id, task.project_id, db, preferred_locale + ) + ) task_dtos = TaskDTOs() task_dtos.tasks = dtos @@ -309,23 +324,34 @@ def get_task_details_for_logged_in_user(user_id: int, preferred_locale: str): return task_dtos @staticmethod - def is_user_in_the_allowed_list(allowed_users: list, current_user_id: int): + async def is_user_in_the_allowed_list( + project_id: int, current_user_id: int, db: Database + ) -> bool: """For private projects, check if user is present in the allowed list""" - return ( - len([user.id for user in allowed_users if user.id == current_user_id]) > 0 + + query = """ + SELECT COUNT(1) + FROM project_allowed_users + WHERE project_id = :project_id AND user_id = :user_id + """ + + result = await db.fetch_val( + query, values={"project_id": project_id, "user_id": current_user_id} ) + # Return True if the user is in the allowed list, False otherwise + return result > 0 @staticmethod - def evaluate_mapping_permission( - project_id: int, user_id: int, mapping_permission: int + async def evaluate_mapping_permission( + project_id: int, user_id: int, mapping_permission: int, db: Database ): allowed_roles = [ TeamRoles.MAPPER.value, TeamRoles.VALIDATOR.value, TeamRoles.PROJECT_MANAGER.value, ] - is_team_member = TeamService.check_team_membership( - project_id, allowed_roles, user_id + is_team_member = await TeamService.check_team_membership( + project_id, allowed_roles, user_id, db ) # mapping_permission = 1(level),2(teams),3(teamsAndLevel) @@ -334,33 +360,37 @@ def evaluate_mapping_permission( return False, MappingNotAllowed.USER_NOT_TEAM_MEMBER elif mapping_permission == MappingPermission.LEVEL.value: - if not ProjectService._is_user_intermediate_or_advanced(user_id): + if not await ProjectService._is_user_intermediate_or_advanced(user_id, db): return False, MappingNotAllowed.USER_NOT_CORRECT_MAPPING_LEVEL elif mapping_permission == MappingPermission.TEAMS_LEVEL.value: - if not ProjectService._is_user_intermediate_or_advanced(user_id): + if not await ProjectService._is_user_intermediate_or_advanced(user_id, db): return False, MappingNotAllowed.USER_NOT_CORRECT_MAPPING_LEVEL if not is_team_member: return False, MappingNotAllowed.USER_NOT_TEAM_MEMBER @staticmethod - def is_user_permitted_to_map(project_id: int, user_id: int): + async def is_user_permitted_to_map(project_id: int, user_id: int, db: Database): """Check if the user is allowed to map the on the project in scope""" - if UserService.is_user_blocked(user_id): + + if await UserService.is_user_blocked(user_id, db): return False, MappingNotAllowed.USER_NOT_ON_ALLOWED_LIST - project = ProjectService.get_project_by_id(project_id) + project = await ProjectService.get_project_by_id(project_id, db) if project.license_id: - if not UserService.has_user_accepted_license(user_id, project.license_id): + if not await UserService.has_user_accepted_license( + user_id, project.license_id, db + ): return False, MappingNotAllowed.USER_NOT_ACCEPTED_LICENSE - mapping_permission = project.mapping_permission + mapping_permission = project.mapping_permission is_manager_permission = ( False # is_admin or is_author or is_org_manager or is_manager_team ) - if ProjectAdminService.is_user_action_permitted_on_project(user_id, project_id): + if await ProjectAdminService.is_user_action_permitted_on_project( + user_id, project_id, db + ): is_manager_permission = True - # Draft (public/private) accessible only for is_manager_permission if ( ProjectStatus(project.status) == ProjectStatus.DRAFT @@ -370,48 +400,47 @@ def is_user_permitted_to_map(project_id: int, user_id: int): is_restriction = None if not is_manager_permission and mapping_permission: - is_restriction = ProjectService.evaluate_mapping_permission( - project_id, user_id, mapping_permission + is_restriction = await ProjectService.evaluate_mapping_permission( + project_id, user_id, mapping_permission, db ) - - tasks = Task.get_locked_tasks_for_user(user_id) + tasks = await Task.get_locked_tasks_for_user(user_id, db) if len(tasks.locked_tasks) > 0: return False, MappingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED is_allowed_user = None if project.private and not is_manager_permission: # Check if user is in allowed user list - is_allowed_user = ProjectService.is_user_in_the_allowed_list( - project.allowed_users, user_id + is_allowed_user = await ProjectService.is_user_in_the_allowed_list( + project.id, user_id, db ) if is_allowed_user: return True, "User allowed to map" if not is_manager_permission and is_restriction: return is_restriction + elif project.private and not ( is_manager_permission or is_allowed_user or not is_restriction ): return False, MappingNotAllowed.USER_NOT_ON_ALLOWED_LIST - return True, "User allowed to map" @staticmethod - def _is_user_intermediate_or_advanced(user_id): + async def _is_user_intermediate_or_advanced(user_id, db: Database): """Helper method to determine if user level is not beginner""" - user_mapping_level = UserService.get_mapping_level(user_id) + user_mapping_level = await UserService.get_mapping_level(user_id, db) if user_mapping_level not in [MappingLevel.INTERMEDIATE, MappingLevel.ADVANCED]: return False return True @staticmethod - def evaluate_validation_permission( - project_id: int, user_id: int, validation_permission: int + async def evaluate_validation_permission( + project_id: int, user_id: int, validation_permission: int, db: Database ): allowed_roles = [TeamRoles.VALIDATOR.value, TeamRoles.PROJECT_MANAGER.value] - is_team_member = TeamService.check_team_membership( - project_id, allowed_roles, user_id + is_team_member = await TeamService.check_team_membership( + project_id, allowed_roles, user_id, db ) # validation_permission = 1(level),2(teams),3(teamsAndLevel) if validation_permission == ValidationPermission.TEAMS.value: @@ -419,30 +448,37 @@ def evaluate_validation_permission( return False, ValidatingNotAllowed.USER_NOT_TEAM_MEMBER elif validation_permission == ValidationPermission.LEVEL.value: - if not ProjectService._is_user_intermediate_or_advanced(user_id): + if not await ProjectService._is_user_intermediate_or_advanced(user_id, db): return False, ValidatingNotAllowed.USER_IS_BEGINNER elif validation_permission == ValidationPermission.TEAMS_LEVEL.value: - if not ProjectService._is_user_intermediate_or_advanced(user_id): + if not await ProjectService._is_user_intermediate_or_advanced(user_id, db): return False, ValidatingNotAllowed.USER_IS_BEGINNER if not is_team_member: return False, ValidatingNotAllowed.USER_NOT_TEAM_MEMBER @staticmethod - def is_user_permitted_to_validate(project_id, user_id): + async def is_user_permitted_to_validate( + project_id: int, user_id: int, db: Database + ): """Check if the user is allowed to validate on the project in scope""" - if UserService.is_user_blocked(user_id): + if await UserService.is_user_blocked(user_id, db): return False, ValidatingNotAllowed.USER_NOT_ON_ALLOWED_LIST - project = ProjectService.get_project_by_id(project_id) + project = await ProjectService.get_project_by_id(project_id, db) if project.license_id: - if not UserService.has_user_accepted_license(user_id, project.license_id): + if not await UserService.has_user_accepted_license( + user_id, project.license_id, db + ): return False, ValidatingNotAllowed.USER_NOT_ACCEPTED_LICENSE + validation_permission = project.validation_permission # is_admin or is_author or is_org_manager or is_manager_team is_manager_permission = False - if ProjectAdminService.is_user_action_permitted_on_project(user_id, project_id): + if await ProjectAdminService.is_user_action_permitted_on_project( + user_id, project_id, db + ): is_manager_permission = True # Draft (public/private) accessible only for is_manager_permission @@ -454,19 +490,19 @@ def is_user_permitted_to_validate(project_id, user_id): is_restriction = None if not is_manager_permission and validation_permission: - is_restriction = ProjectService.evaluate_validation_permission( - project_id, user_id, validation_permission + is_restriction = await ProjectService.evaluate_validation_permission( + project_id, user_id, validation_permission, db ) - tasks = Task.get_locked_tasks_for_user(user_id) + tasks = await Task.get_locked_tasks_for_user(user_id, db) if len(tasks.locked_tasks) > 0: return False, ValidatingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED is_allowed_user = None if project.private and not is_manager_permission: # Check if user is in allowed user list - is_allowed_user = ProjectService.is_user_in_the_allowed_list( - project.allowed_users, user_id + is_allowed_user = await ProjectService.is_user_in_the_allowed_list( + project_id, user_id, db ) if is_allowed_user: @@ -481,8 +517,12 @@ def is_user_permitted_to_validate(project_id, user_id): return True, "User allowed to validate" + def summary_cache_key_builder(func, *args, **kwargs): + args_without_db = args[:-1] + return f"{func.__name__}:{args_without_db}:{kwargs}" + @staticmethod - @cached(summary_cache) + @cached(cache=Cache.MEMORY, key_builder=summary_cache_key_builder, ttl=600) def get_cached_project_summary( project_id: int, preferred_locale: str = "en" ) -> ProjectSummary: @@ -492,65 +532,140 @@ def get_cached_project_summary( return project.get_project_summary(preferred_locale, calculate_completion=False) @staticmethod - def get_project_summary( - project_id: int, preferred_locale: str = "en" + async def get_project_summary( + project_id: int, db: Database, preferred_locale: str = "en" ) -> ProjectSummary: + query = """ + SELECT + p.id AS id, + p.difficulty, + p.priority, + p.default_locale, + ST_AsGeoJSON(p.centroid) AS centroid, + p.organisation_id, + p.tasks_bad_imagery, + p.tasks_mapped, + p.tasks_validated, + p.status, + p.mapping_types, + p.total_tasks, + p.last_updated, + p.due_date, + p.country, + p.changeset_comment, + p.created, + p.osmcha_filter_id, + p.mapping_permission, + p.validation_permission, + p.enforce_random_task_selection, + p.private, + p.license_id, + p.id_presets, + p.extra_id_params, + p.rapid_power_user, + p.imagery, + p.mapping_editors, + p.validation_editors, + u.username AS author, + o.name AS organisation_name, + o.slug AS organisation_slug, + o.logo AS organisation_logo, + ARRAY(SELECT user_id FROM project_allowed_users WHERE project_id = p.id) AS allowed_users + FROM projects p + LEFT JOIN organisations o ON o.id = p.organisation_id + LEFT JOIN users u ON u.id = p.author_id + WHERE p.id = :id + """ + params = {"id": project_id} + # Execute query + project = await db.fetch_one(query, params) + if not project: + raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) """Gets the project summary DTO""" - project = ProjectService.get_project_by_id(project_id) - summary = ProjectService.get_cached_project_summary( - project_id, preferred_locale + + summary = await Project.get_project_summary( + project, preferred_locale, db, calculate_completion=False + ) + summary.percent_mapped = Project.calculate_tasks_percent( + "mapped", + project.tasks_mapped, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, + ) + summary.percent_validated = Project.calculate_tasks_percent( + "validated", + project.tasks_validated, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, + ) + summary.percent_bad_imagery = Project.calculate_tasks_percent( + "bad_imagery", + project.tasks_mapped, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, ) - # Since we don't want to cache the project stats, we need to update them - summary.percent_mapped = project.calculate_tasks_percent("mapped") - summary.percent_validated = project.calculate_tasks_percent("validated") - summary.percent_bad_imagery = project.calculate_tasks_percent("bad_imagery") return summary @staticmethod - def set_project_as_featured(project_id: int): + async def set_project_as_featured(project_id: int, db: Database): """Sets project as featured""" - project = ProjectService.get_project_by_id(project_id) - project.set_as_featured() + project = await ProjectService.get_project_by_id(project_id, db) + await Project.set_as_featured(project, db) @staticmethod - def unset_project_as_featured(project_id: int): + async def unset_project_as_featured(project_id: int, db: Database): """Sets project as featured""" - project = ProjectService.get_project_by_id(project_id) - project.unset_as_featured() + project = await ProjectService.get_project_by_id(project_id, db) + await Project.unset_as_featured(project, db) @staticmethod - def get_featured_projects(preferred_locale): - """Sets project as featured""" - query = ProjectSearchService.create_search_query() - projects = query.filter(Project.featured == true()).group_by(Project.id).all() + async def get_featured_projects( + preferred_locale: str, db: Database + ) -> ProjectSearchResultsDTO: + """Fetch featured projects and return results.""" + + # Create the search query + query, params = await ProjectSearchService.create_search_query(db) - # Get total contributors. - contrib_counts = ProjectSearchService.get_total_contributions(projects) + # Append filtering for featured projects + query += " AND p.featured = TRUE" + + projects = await db.fetch_all(query, params) + project_ids = [project["id"] for project in projects] + + # Get total contributors + contrib_counts = await ProjectSearchService.get_total_contributions( + project_ids, db + ) zip_items = zip(projects, contrib_counts) dto = ProjectSearchResultsDTO() dto.results = [ - ProjectSearchService.create_result_dto(p, preferred_locale, t) - for p, t in zip_items + await ProjectSearchService.create_result_dto( + project, preferred_locale, total_contributors, db + ) + for project, total_contributors in zip_items ] - + dto.pagination = None return dto @staticmethod - def is_favorited(project_id: int, user_id: int) -> bool: - project = ProjectService.get_project_by_id(project_id) - - return project.is_favorited(user_id) + async def is_favorited(project_id: int, user_id: int, db: Database) -> bool: + await ProjectService.exists(project_id, db) + return await Project.is_favorited(project_id, user_id, db) @staticmethod - def favorite(project_id: int, user_id: int): - project = ProjectService.get_project_by_id(project_id) - project.favorite(user_id) + async def favorite(project_id: int, user_id: int, db: Database): + await ProjectService.exists(project_id, db) + await Project.favorite(project_id, user_id, db) @staticmethod - def unfavorite(project_id: int, user_id: int): - project = ProjectService.get_project_by_id(project_id) - project.unfavorite(user_id) + async def unfavorite(project_id: int, user_id: int, db: Database): + await ProjectService.exists(project_id, db) + await Project.unfavorite(project_id, user_id, db) @staticmethod def get_project_title(project_id: int, preferred_locale: str = "en") -> str: @@ -558,19 +673,25 @@ def get_project_title(project_id: int, preferred_locale: str = "en") -> str: project = ProjectService.get_project_by_id(project_id) return project.get_project_title(preferred_locale) + def stats_cache_key_builder(func, *args, **kwargs): + args_without_db = args[:-1] + return f"{func.__name__}:{args_without_db}:{kwargs}" + @staticmethod - @cached(TTLCache(maxsize=1024, ttl=600)) - def get_project_stats(project_id: int) -> ProjectStatsDTO: + @cached(cache=Cache.MEMORY, key_builder=stats_cache_key_builder, ttl=600) + async def get_project_stats(project_id: int, db: Database) -> ProjectStatsDTO: """Gets the project stats DTO""" - project = ProjectService.get_project_by_id(project_id) - return project.get_project_stats() + await ProjectService.exists(project_id, db) + return await Project.get_project_stats(project_id, db) @staticmethod - def get_project_user_stats(project_id: int, username: str) -> ProjectUserStatsDTO: + async def get_project_user_stats( + project_id: int, username: str, db: Database + ) -> ProjectUserStatsDTO: """Gets the user stats for a specific project""" - project = ProjectService.get_project_by_id(project_id) - user = UserService.get_user_by_username(username) - return project.get_project_user_stats(user.id) + await ProjectService.exists(project_id, db) + user = await UserService.get_user_by_username(username, db) + return await Project.get_project_user_stats(project_id, user.id, db) def get_project_teams(project_id: int): project = ProjectService.get_project_by_id(project_id) @@ -590,74 +711,101 @@ def get_project_organisation(project_id: int) -> Organisation: return project.organisation @staticmethod - def send_email_on_project_progress(project_id): + async def send_email_on_project_progress(project_id: int): """Send email to all contributors on project progress""" - if not current_app.config["SEND_PROJECT_EMAIL_UPDATES"]: - return - project = ProjectService.get_project_by_id(project_id) - - project_completion = project.calculate_tasks_percent("project_completion") - if project_completion == 50 and project.progress_email_sent: - return # Don't send progress email if it's already sent - if project_completion in [50, 100]: - email_type = ( - EncouragingEmailType.PROJECT_COMPLETE.value - if project_completion == 100 - else EncouragingEmailType.PROJECT_PROGRESS.value + async with db_connection.database.connection() as db: + current_settings = get_settings() + if not current_settings.SEND_PROJECT_EMAIL_UPDATES: + return + project = await ProjectService.get_project_by_id(project_id, db) + + project_completion = Project.calculate_tasks_percent( + "project_completion", + project.tasks_mapped, + project.tasks_validated, + project.total_tasks, + project.tasks_bad_imagery, ) - project_title = ProjectInfo.get_dto_for_locale( - project_id, project.default_locale - ).name - project.progress_email_sent = True - project.save() - threading.Thread( - target=SMTPService.send_email_to_contributors_on_project_progress, - args=( - email_type, - project_id, - project_title, - project_completion, - ), - ).start() - - @staticmethod - def get_active_projects(interval): - action_date = datetime.utcnow() - timedelta(hours=interval) - history_result = ( - TaskHistory.query.with_entities(TaskHistory.project_id) - .distinct() - .filter((TaskHistory.action_date) >= action_date) - .all() + if project_completion == 50 and project.progress_email_sent: + return # Don't send progress email if it's already sent + if project_completion in [50, 100]: + email_type = ( + EncouragingEmailType.PROJECT_COMPLETE.value + if project_completion == 100 + else EncouragingEmailType.PROJECT_PROGRESS.value + ) + project_title_query = """ + SELECT name + FROM project_info + WHERE project_id = :project_id AND locale = :locale + """ + project_title = await db.fetch_val( + project_title_query, + values={ + "project_id": project_id, + "locale": project.default_locale, + }, + ) + + # Update progress_email_sent status + await db.execute( + """ + UPDATE projects + SET progress_email_sent = TRUE + WHERE id = :project_id + """, + values={"project_id": project_id}, + ) + await SMTPService.send_email_to_contributors_on_project_progress( + email_type, project_id, project_title, project_completion, db + ) + + @staticmethod + async def get_active_projects(interval: int, db: Database): + # Calculate the action_date and make it naive + action_date = (datetime.now(timezone.utc) - timedelta(hours=interval)).replace( + tzinfo=None ) - project_ids = [row.project_id for row in history_result] - chat_result = ( - ProjectChat.query.with_entities(ProjectChat.project_id) - .distinct() - .filter((ProjectChat.time_stamp) >= action_date) - .all() + # First query to get distinct project_ids + query_project_ids = """ + SELECT DISTINCT project_id + FROM task_history + WHERE action_date >= :action_date + """ + project_ids_result = await db.fetch_all( + query_project_ids, {"action_date": action_date} ) - chat_project_ids = [row.project_id for row in chat_result] - project_ids.extend(chat_project_ids) - project_ids = list(set(project_ids)) - projects = ( - Project.query.with_entities( - Project.id, - Project.mapping_types, - Project.geometry.ST_AsGeoJSON().label("geometry"), - ) - .filter( - Project.id.in_(project_ids), - ) - .all() + project_ids = [row["project_id"] for row in project_ids_result] + + # If there are no project IDs, return an empty FeatureCollection + if not project_ids: + return geojson.FeatureCollection([]) + + # Second query to get project details + query_projects = """ + SELECT + id, + mapping_types, + ST_AsGeoJSON(geometry) AS geometry + FROM projects + WHERE status = :status + AND id = ANY(:project_ids) + """ + project_result = await db.fetch_all( + query_projects, + {"status": ProjectStatus.PUBLISHED.value, "project_ids": project_ids}, ) - features = [] - for project in projects: - properties = { - "project_id": project.id, - "mapping_types": project.mapping_types, - } - feature = geojson.Feature( - geometry=geojson.loads(project.geometry), properties=properties + + # Building GeoJSON FeatureCollection + features = [ + geojson.Feature( + geometry=geojson.loads(project["geometry"]), + properties={ + "project_id": project["id"], + "mapping_types": project["mapping_types"], + }, ) - features.append(feature) + for project in project_result + ] + return geojson.FeatureCollection(features) diff --git a/backend/services/recommendation_service.py b/backend/services/recommendation_service.py index a53289744c..483f637906 100644 --- a/backend/services/recommendation_service.py +++ b/backend/services/recommendation_service.py @@ -1,15 +1,14 @@ import pandas as pd +from aiocache import Cache, cached +from cachetools import TTLCache +from databases import Database from sklearn.metrics.pairwise import cosine_similarity from sklearn.preprocessing import MultiLabelBinarizer -from sqlalchemy.orm import joinedload -from sqlalchemy.sql.expression import func -from cachetools import TTLCache, cached -from backend import db from backend.exceptions import NotFound -from backend.models.postgis.project import Project, Interest, project_interests -from backend.models.postgis.statuses import ProjectStatus from backend.models.dtos.project_dto import ProjectSearchResultsDTO +from backend.models.postgis.project import Project +from backend.models.postgis.statuses import ProjectStatus from backend.services.project_search_service import ProjectSearchService from backend.services.users.user_service import UserService @@ -26,52 +25,6 @@ class ProjectRecommendationService: - @staticmethod - def to_dataframe(records, columns: list): - """Convert records fetched from sql execution into dataframe - :param records: records fetched from sql execution - :param columns: columns of the dataframe - :return: dataframe - """ - batch_rows = list() - for _, row in enumerate(records, start=0): - batch_rows.append(row) - table = pd.DataFrame(batch_rows, columns=columns) - return table - - @staticmethod - def get_all_published_projects(): - """Gets all published projects - :return: list of published projects - """ - # Create a subquery to fetch the interests of the projects - subquery = ( - db.session.query( - project_interests.c.project_id, Interest.id.label("interest_id") - ) - .join(Interest) - .subquery() - ) - - # Only fetch the columns required for recommendation - # Should be in order of the columns defined in the project_columns line 13 - query = Project.query.options(joinedload(Project.interests)).with_entities( - Project.id, - Project.default_locale, - Project.difficulty, - Project.mapping_types, - Project.country, - func.array_agg(subquery.c.interest_id).label("interests"), - ) - # Outerjoin so that projects without interests are also returned - query = ( - query.outerjoin(subquery, Project.id == subquery.c.project_id) - .filter(Project.status == ProjectStatus.PUBLISHED.value) - .group_by(Project.id) - ) - result = query.all() - return result - @staticmethod def mlb_transform(table, column, prefix): """Transforms multi label column into multiple columns and retruns the data frame with new columns @@ -165,89 +118,119 @@ def get_similar_project_ids(all_projects_df, target_project_df): return similar_projects + def matrix_cache_key_builder(func, *args, **kwargs): + # Remove the last two arguments + args_without_db = args[:-1] + return f"{func.__name__}:{args_without_db}:{kwargs}" + # This function is cached so that the matrix is not calculated every time # as it is expensive and not changing often @staticmethod - @cached(cache=similar_projects_cache) - def create_project_matrix(target_project=None): - """Creates project matrix that is required to calculate the similarity - :param target_project: target project id (not used). - This is required to reset the cache when a new project is published - :return: project matrix data frame with encoded columns + @cached(cache=Cache.MEMORY, key_builder=matrix_cache_key_builder, ttl=3600) + async def create_project_matrix(db: Database, target_project=None) -> pd.DataFrame: + """Creates project matrix required to calculate similarity.""" + # Query to fetch all published projects with their related data + query = """ + SELECT p.id, p.default_locale, p.difficulty, p.mapping_types, p.country, + COALESCE(ARRAY_AGG(pi.interest_id), ARRAY[]::INTEGER[]) AS categories + FROM projects p + LEFT JOIN ( + SELECT pi.project_id, i.id as interest_id + FROM project_interests pi + JOIN interests i ON pi.interest_id = i.id + ) pi ON p.id = pi.project_id + WHERE p.status = :status + GROUP BY p.id """ - all_projects = ProjectRecommendationService.get_all_published_projects() - all_projects_df = ProjectRecommendationService.to_dataframe( - all_projects, project_columns - ) - all_projects_df = ProjectRecommendationService.build_encoded_data_frame( - all_projects_df - ) - return all_projects_df + try: + # Execute the query and fetch results + result = await db.fetch_all( + query=query, values={"status": ProjectStatus.PUBLISHED.value} + ) + # Convert the result into a DataFrame + df = pd.DataFrame([dict(row) for row in result]) + # Optionally encode categorical data + df = ProjectRecommendationService.build_encoded_data_frame(df) + return df + + except Exception as e: + print(f"An error occurred: {e}") + return pd.DataFrame() @staticmethod - def get_similar_projects( - project_id, user_id=None, preferred_locale="en", limit=4 + async def get_similar_projects( + db: Database, + project_id: int, + user_id: str = None, + preferred_locale: str = "en", + limit: int = 4, ) -> ProjectSearchResultsDTO: - """Get similar projects based on the given project ID. - ---------------------------------------- - :param project_id: project id - :param preferred_locale: preferred locale - :return: list of similar projects in the order of similarity - """ - target_project = Project.query.get(project_id) - # Check if the project exists and is published - project_is_published = ( - target_project and target_project.status == ProjectStatus.PUBLISHED.value + """Get similar projects based on the given project ID.""" + + # Fetch the target project details + target_project_query = "SELECT * FROM projects WHERE id = :project_id" + target_project = await db.fetch_one( + query=target_project_query, values={"project_id": project_id} ) - if not project_is_published: + + if ( + not target_project + or target_project["status"] != ProjectStatus.PUBLISHED.value + ): raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) - projects_df = ProjectRecommendationService.create_project_matrix() + # Create the project similarity matrix + projects_df = await ProjectRecommendationService.create_project_matrix(db) target_project_df = projects_df[projects_df["id"] == project_id] + if target_project_df.empty: - # If the target project is not in the projects_df then it means it is published - # but not yet in the cache of create_project_matrix. So we need to update the cache. - projects_df = ProjectRecommendationService.create_project_matrix( - target_project=project_id + projects_df = await ProjectRecommendationService.create_project_matrix( + db, target_project=project_id ) target_project_df = projects_df[projects_df["id"] == project_id] dto = ProjectSearchResultsDTO() - # If there is only one project then return empty list as there is no other project to compare + dto.pagination = None if projects_df.shape[0] < 2: return dto + # Get IDs of similar projects similar_projects = ProjectRecommendationService.get_similar_project_ids( projects_df, target_project_df ) + user = await UserService.get_user_by_id(user_id, db) if user_id else None - user = UserService.get_user_by_id(user_id) if user_id else None + # Create the search query with filters applied based on user role + search_query, params = await ProjectSearchService.create_search_query(db, user) - query = ProjectSearchService.create_search_query(user) - # Only return projects which are not completed - query = query.filter( - Project.total_tasks != Project.tasks_validated + Project.tasks_bad_imagery - ) + # Filter out fully completed projects + search_query += """ + AND (p.total_tasks != p.tasks_validated + p.tasks_bad_imagery) + AND p.id = :project_id + """ - # Set the limit to the number of similar projects if it is less than the limit + # Limit the number of similar projects to fetch limit = min(limit, len(similar_projects)) if similar_projects else 0 - count = 0 while len(dto.results) < limit: - # In case the user is not authorized to view the project and similar projects are less than the limit - # then we need to break the loop and return the results try: - project_id = similar_projects[count] + similar_project_id = similar_projects[count] except IndexError: break - project = query.filter(Project.id == project_id).all() + project = await db.fetch_one( + query=search_query, values={**params, "project_id": similar_project_id} + ) if project: dto.results.append( - ProjectSearchService.create_result_dto( - project[0], + await ProjectSearchService.create_result_dto( + project, preferred_locale, - Project.get_project_total_contributions(project[0][0]), + await Project.get_project_total_contributions( + project["id"], db + ), + db, ) ) count += 1 + return dto diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py index f4e52dd2b8..b1e8369c70 100644 --- a/backend/services/settings_service.py +++ b/backend/services/settings_service.py @@ -1,6 +1,7 @@ from cachetools import TTLCache, cached -from flask import current_app -from backend.models.dtos.settings_dto import SupportedLanguage, SettingsDTO + +from backend.config import settings +from backend.models.dtos.settings_dto import SettingsDTO, SupportedLanguage settings_cache = TTLCache(maxsize=4, ttl=300) @@ -11,17 +12,15 @@ class SettingsService: def get_settings(): """Gets all settings required by the client""" settings_dto = SettingsDTO() - settings_dto.mapper_level_advanced = current_app.config["MAPPER_LEVEL_ADVANCED"] - settings_dto.mapper_level_intermediate = current_app.config[ - "MAPPER_LEVEL_INTERMEDIATE" - ] + settings_dto.mapper_level_advanced = settings.MAPPER_LEVEL_ADVANCED + settings_dto.mapper_level_intermediate = settings.MAPPER_LEVEL_INTERMEDIATE settings_dto.supported_languages = SettingsService.get_supported_languages() return settings_dto @staticmethod def get_supported_languages(): """Gets all supported languages from the config""" - app_languages = current_app.config["SUPPORTED_LANGUAGES"] + app_languages = settings.SUPPORTED_LANGUAGES codes = [x.strip() for x in app_languages["codes"].split(",")] languages = [x.strip() for x in app_languages["languages"].split(",")] diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py index 725ac2d05b..d60c6dd44f 100644 --- a/backend/services/stats_service.py +++ b/backend/services/stats_service.py @@ -1,97 +1,98 @@ -from cachetools import TTLCache, cached +import datetime from datetime import date, timedelta -from sqlalchemy import func, desc, cast, extract, or_ -from sqlalchemy.sql.functions import coalesce -from sqlalchemy.types import Time -from backend import db -from backend.exceptions import NotFound +from aiocache import Cache, cached +from databases import Database +from sqlalchemy import func, select + +from backend.models.dtos.project_dto import ProjectSearchResultsDTO from backend.models.dtos.stats_dto import ( - ProjectContributionsDTO, - UserContribution, + CampaignStatsDTO, + GenderStatsDTO, + HomePageStatsDTO, + OrganizationListStatsDTO, Pagination, - TaskHistoryDTO, - TaskStatusDTO, ProjectActivityDTO, + ProjectContributionsDTO, ProjectLastActivityDTO, - HomePageStatsDTO, - OrganizationListStatsDTO, - CampaignStatsDTO, - TaskStats, + TaskHistoryDTO, TaskStatsDTO, - GenderStatsDTO, + TaskStatusDTO, + UserContribution, UserStatsDTO, ) - -from backend.models.dtos.project_dto import ProjectSearchResultsDTO from backend.models.postgis.campaign import Campaign, campaign_projects from backend.models.postgis.organisation import Organisation from backend.models.postgis.project import Project -from backend.models.postgis.statuses import TaskStatus, MappingLevel, UserGender -from backend.models.postgis.task import TaskHistory, User, Task, TaskAction +from backend.models.postgis.statuses import MappingLevel, TaskStatus, UserGender +from backend.models.postgis.task import Task, TaskAction, User from backend.models.postgis.utils import timestamp # noqa: F401 -from backend.services.project_service import ProjectService from backend.services.project_search_service import ProjectSearchService +from backend.services.project_service import ProjectService from backend.services.users.user_service import UserService -from backend.services.organisation_service import OrganisationService -from backend.services.campaign_service import CampaignService - -homepage_stats_cache = TTLCache(maxsize=4, ttl=30) class StatsService: @staticmethod - def update_stats_after_task_state_change( + async def update_stats_after_task_state_change( project_id: int, user_id: int, last_state: TaskStatus, new_state: TaskStatus, - action="change", - local_session=None, + db: Database, + action: str = "change", ): """Update stats when a task has had a state change""" + # No stats to record for these states if new_state in [ TaskStatus.LOCKED_FOR_VALIDATION, TaskStatus.LOCKED_FOR_MAPPING, ]: - return # No stats to record for these states - - project = ProjectService.get_project_by_id(project_id) - user = UserService.get_user_by_id(user_id) - - project, user = StatsService._update_tasks_stats( - project, user, last_state, new_state, action + return + project = await ProjectService.get_project_by_id(project_id, db) + user = await UserService.get_user_by_id(user_id, db) + project, user = await StatsService._update_tasks_stats( + project, user, last_state, new_state, db, action ) - UserService.upsert_mapped_projects( - user_id, project_id, local_session=local_session + # Upsert mapped projects for the user + await UserService.upsert_mapped_projects(user_id, project_id, db) + query = """ + UPDATE projects + SET last_updated = :last_updated + WHERE id = :project_id + """ + await db.execute( + query, + values={ + "last_updated": datetime.datetime.utcnow(), + "project_id": project_id, + }, ) - project.last_updated = timestamp() - - # Transaction will be saved when task is saved return project, user @staticmethod - def _update_tasks_stats( - project: Project, - user: User, + async def _update_tasks_stats( + project: dict, + user: dict, last_state: TaskStatus, new_state: TaskStatus, + db: Database, action="change", ): - # Make sure you are aware that users table has it as incrementing counters, - # while projects table reflect the actual state, and both increment and decrement happens if new_state == last_state: return project, user - # Set counters for new state + # Increment counters for the new state if new_state == TaskStatus.MAPPED: project.tasks_mapped += 1 + elif new_state == TaskStatus.VALIDATED: project.tasks_validated += 1 elif new_state == TaskStatus.BADIMAGERY: project.tasks_bad_imagery += 1 + # Increment user stats if action is "change" if action == "change": if new_state == TaskStatus.MAPPED: user.tasks_mapped += 1 @@ -100,14 +101,16 @@ def _update_tasks_stats( elif new_state == TaskStatus.INVALIDATED: user.tasks_invalidated += 1 - # Remove counters for old state + # Decrement counters for the old state if last_state == TaskStatus.MAPPED: project.tasks_mapped -= 1 elif last_state == TaskStatus.VALIDATED: project.tasks_validated -= 1 + elif last_state == TaskStatus.BADIMAGERY: project.tasks_bad_imagery -= 1 + # Undo user stats if action is "undo" if action == "undo": if last_state == TaskStatus.MAPPED: user.tasks_mapped -= 1 @@ -116,330 +119,402 @@ def _update_tasks_stats( elif last_state == TaskStatus.INVALIDATED: user.tasks_invalidated -= 1 + # Update the project and user records in the database + await db.execute( + """ + UPDATE projects + SET tasks_mapped = :tasks_mapped, + tasks_validated = :tasks_validated, + tasks_bad_imagery = :tasks_bad_imagery + WHERE id = :project_id + """, + values={ + "tasks_mapped": project.tasks_mapped, + "tasks_validated": project.tasks_validated, + "tasks_bad_imagery": project.tasks_bad_imagery, + "project_id": project.id, + }, + ) + + await db.execute( + """ + UPDATE users + SET tasks_mapped = :tasks_mapped, + tasks_validated = :tasks_validated, + tasks_invalidated = :tasks_invalidated + WHERE id = :user_id + """, + values={ + "tasks_mapped": user.tasks_mapped, + "tasks_validated": user.tasks_validated, + "tasks_invalidated": user.tasks_invalidated, + "user_id": user.id, + }, + ) return project, user @staticmethod - def get_latest_activity(project_id: int, page: int) -> ProjectActivityDTO: + async def get_latest_activity( + project_id: int, page: int, db: Database + ) -> ProjectActivityDTO: """Gets all the activity on a project""" - if not ProjectService.exists(project_id): - raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) - - results = ( - db.session.query( - TaskHistory.id, - TaskHistory.task_id, - TaskHistory.action, - TaskHistory.action_date, - TaskHistory.action_text, - User.username, - ) - .join(User) - .filter( - TaskHistory.project_id == project_id, - TaskHistory.action != TaskAction.COMMENT.name, - ) - .order_by(TaskHistory.action_date.desc()) - .paginate(page=page, per_page=10, error_out=True) + # Pagination setup + page_size = 10 + offset = (page - 1) * page_size + + # Query to fetch task history + query = """ + SELECT + th.id, + th.task_id, + th.action, + th.action_date, + th.action_text, + u.username + FROM task_history th + JOIN users u ON th.user_id = u.id + WHERE + th.project_id = :project_id + AND th.action != :comment_action + ORDER BY th.action_date DESC + LIMIT :limit OFFSET :offset + """ + rows = await db.fetch_all( + query, + { + "project_id": project_id, + "comment_action": "COMMENT", + "limit": page_size, + "offset": offset, + }, ) - activity_dto = ProjectActivityDTO() - for item in results.items: - history = TaskHistoryDTO() - history.task_id = item.id - history.task_id = item.task_id - history.action = item.action - history.action_text = item.action_text - history.action_date = item.action_date - history.action_by = item.username + # Creating DTO + activity_dto = ProjectActivityDTO(activity=[]) + for row in rows: + history = TaskHistoryDTO( + history_id=row["id"], + task_id=row["task_id"], + action=row["action"], + action_text=row["action_text"], + action_date=row["action_date"], + action_by=row["username"], + ) activity_dto.activity.append(history) - activity_dto.pagination = Pagination(results) - return activity_dto + # Calculate total items for pagination + total_query = """ + SELECT COUNT(*) + FROM task_history th + WHERE + th.project_id = :project_id + AND th.action != :comment_action + """ + total_items_result = await db.fetch_one( + total_query, {"project_id": project_id, "comment_action": "COMMENT"} + ) - @staticmethod - def get_popular_projects() -> ProjectSearchResultsDTO: - """Get all projects ordered by task_history""" + total_items = total_items_result["count"] if total_items_result else 0 - rate_func = func.count(TaskHistory.user_id) / extract( - "epoch", func.sum(cast(TaskHistory.action_date, Time)) + # Use the from_total_count method to correctly initialize the Pagination DTO + activity_dto.pagination = Pagination.from_total_count( + page=page, per_page=page_size, total=total_items ) - query = ( - TaskHistory.query.with_entities( - TaskHistory.project_id.label("id"), rate_func.label("rate") - ) - .filter(TaskHistory.action_date >= date.today() - timedelta(days=90)) - .filter( - or_( - TaskHistory.action == TaskAction.LOCKED_FOR_MAPPING.name, - TaskHistory.action == TaskAction.LOCKED_FOR_VALIDATION.name, - ) - ) - .filter(TaskHistory.action_text is not None) - .filter(TaskHistory.action_text != "") - .group_by(TaskHistory.project_id) - .order_by(desc("rate")) - .limit(10) - .subquery() - ) + return activity_dto - projects_query = ProjectSearchService.create_search_query() - projects = projects_query.filter(Project.id == query.c.id).all() - # Get total contributors. - contrib_counts = ProjectSearchService.get_total_contributions(projects) + @staticmethod + async def get_popular_projects(db: Database) -> ProjectSearchResultsDTO: + """Get all projects ordered by task history.""" + + # Query to calculate the "popularity" rate based on task history + popularity_query = """ + SELECT + th.project_id AS id, + COUNT(th.user_id) / EXTRACT(EPOCH FROM SUM(th.action_date::time)) AS rate + FROM task_history th + WHERE th.action_date >= :start_date + AND (th.action = :locked_for_mapping OR th.action = :locked_for_validation) + AND th.action_text IS NOT NULL + AND th.action_text != '' + GROUP BY th.project_id + ORDER BY rate DESC + LIMIT 10 + """ + + start_date = date.today() - timedelta(days=90) + params = { + "start_date": start_date, + "locked_for_mapping": TaskAction.LOCKED_FOR_MAPPING.name, + "locked_for_validation": TaskAction.LOCKED_FOR_VALIDATION.name, + } + + # Fetch the popular projects based on the rate calculated above + popular_projects = await db.fetch_all(popularity_query, params) + project_ids = [row["id"] for row in popular_projects] + + if not project_ids: + return ProjectSearchResultsDTO(results=[]) + + # Use the existing `create_search_query` function to fetch detailed project data + project_query, query_params = await ProjectSearchService.create_search_query(db) + project_query += " AND p.id = ANY(:project_ids)" + query_params["project_ids"] = project_ids + + projects = await db.fetch_all(project_query, query_params) + + # Get total contributors for each project + contrib_counts = await ProjectSearchService.get_total_contributions( + project_ids, db + ) zip_items = zip(projects, contrib_counts) + # Prepare the final DTO with all project details dto = ProjectSearchResultsDTO() dto.results = [ - ProjectSearchService.create_result_dto(p, "en", t) for p, t in zip_items + await ProjectSearchService.create_result_dto(p, "en", t, db) + for p, t in zip_items ] return dto @staticmethod - def get_last_activity(project_id: int) -> ProjectLastActivityDTO: + async def get_last_activity( + project_id: int, db: Database + ) -> ProjectLastActivityDTO: """Gets the last activity for a project's tasks""" - sq = ( - TaskHistory.query.with_entities( - TaskHistory.task_id, - TaskHistory.action_date, - TaskHistory.user_id, - ) - .filter(TaskHistory.project_id == project_id) - .filter(TaskHistory.action != TaskAction.COMMENT.name) - .order_by(TaskHistory.task_id, TaskHistory.action_date.desc()) - .distinct(TaskHistory.task_id) - .subquery() - ) - sq_statuses = ( - Task.query.with_entities(Task.id, Task.task_status) - .filter(Task.project_id == project_id) - .subquery() - ) - results = ( - db.session.query( - sq_statuses.c.id, - sq.c.action_date, - sq_statuses.c.task_status, - User.username, - ) - .outerjoin(sq, sq.c.task_id == sq_statuses.c.id) - .outerjoin(User, User.id == sq.c.user_id) - .order_by(sq_statuses.c.id) - .all() + # Subquery: Fetch latest actions for each task, excluding comments + subquery_latest_action = """ + SELECT DISTINCT ON (th.task_id) + th.task_id, + th.action_date, + th.user_id + FROM task_history th + WHERE th.project_id = :project_id + AND th.action != :comment_action + ORDER BY th.task_id, th.action_date DESC + """ + + # Main query: Join task statuses with latest actions and user details + query_task_statuses = f""" + SELECT + t.id AS task_id, + t.task_status, + la.action_date, + u.username AS action_by + FROM tasks t + LEFT JOIN ({subquery_latest_action}) la ON la.task_id = t.id + LEFT JOIN users u ON u.id = la.user_id + WHERE t.project_id = :project_id + ORDER BY t.id + """ + + # Execute the query + results = await db.fetch_all( + query_task_statuses, {"project_id": project_id, "comment_action": "COMMENT"} ) - dto = ProjectLastActivityDTO() - dto.activity = [ - TaskStatusDTO( - dict( - task_id=r.id, - task_status=TaskStatus(r.task_status).name, - action_date=r.action_date, - action_by=r.username, - ) + # Create DTO + dto = ProjectLastActivityDTO(activity=[]) + for row in results: + task_status_dto = TaskStatusDTO( + task_id=row["task_id"], + task_status=TaskStatus(row["task_status"]).name, + action_date=row["action_date"], + action_by=row["action_by"], ) - for r in results - ] + dto.activity.append(task_status_dto) return dto @staticmethod - def get_user_contributions(project_id: int) -> ProjectContributionsDTO: - """Get all user contributions on a project""" - - mapped_stmt = ( - Task.query.with_entities( - Task.mapped_by, - func.count(Task.mapped_by).label("count"), - func.array_agg(Task.id).label("task_ids"), - ) - .filter(Task.project_id == project_id) - .filter(Task.task_status != TaskStatus.BADIMAGERY.value) - .group_by(Task.mapped_by) - .subquery() - ) - badimagery_stmt = ( - Task.query.with_entities( - Task.mapped_by, - func.count(Task.mapped_by).label("count"), - func.array_agg(Task.id).label("task_ids"), - ) - .filter(Task.project_id == project_id) - .filter(Task.task_status == TaskStatus.BADIMAGERY.value) - .group_by(Task.mapped_by) - .subquery() - ) - validated_stmt = ( - Task.query.with_entities( - Task.validated_by, - func.count(Task.validated_by).label("count"), - func.array_agg(Task.id).label("task_ids"), - ) - .filter(Task.project_id == project_id) - .group_by(Task.validated_by) - .subquery() - ) - - project_contributions = ( - TaskHistory.query.with_entities(TaskHistory.user_id) - .filter( - TaskHistory.project_id == project_id, TaskHistory.action != "COMMENT" - ) - .distinct(TaskHistory.user_id) - .subquery() - ) - - results = ( - db.session.query( - User.id, - User.username, - User.name, - User.mapping_level, - User.picture_url, - User.date_registered, - coalesce(mapped_stmt.c.count, 0).label("mapped"), - coalesce(validated_stmt.c.count, 0).label("validated"), - coalesce(badimagery_stmt.c.count, 0).label("bad_imagery"), - ( - coalesce(mapped_stmt.c.count, 0) - + coalesce(validated_stmt.c.count, 0) - + coalesce(badimagery_stmt.c.count, 0) - ).label("total"), - mapped_stmt.c.task_ids.label("mapped_tasks"), - validated_stmt.c.task_ids.label("validated_tasks"), - badimagery_stmt.c.task_ids.label("bad_imagery_tasks"), - ) - .join(project_contributions, User.id == project_contributions.c.user_id) - .outerjoin(mapped_stmt, User.id == mapped_stmt.c.mapped_by) - .outerjoin(badimagery_stmt, User.id == badimagery_stmt.c.mapped_by) - .outerjoin(validated_stmt, User.id == validated_stmt.c.validated_by) - .group_by( - User.id, - User.username, - User.name, - User.mapping_level, - User.picture_url, - User.date_registered, - mapped_stmt.c.count, - mapped_stmt.c.task_ids, - badimagery_stmt.c.count, - badimagery_stmt.c.task_ids, - validated_stmt.c.count, - validated_stmt.c.task_ids, - ) - .order_by(desc("total")) - .all() + async def get_user_contributions( + project_id: int, db: Database + ) -> ProjectContributionsDTO: + # Query to get user contributions + query = """ + WITH mapped AS ( + SELECT + mapped_by AS user_id, + COUNT(mapped_by) AS count, + ARRAY_AGG(id) AS task_ids + FROM tasks + WHERE project_id = :project_id + AND task_status != :bad_imagery_status + GROUP BY mapped_by + ), + badimagery AS ( + SELECT + mapped_by AS user_id, + COUNT(mapped_by) AS count, + ARRAY_AGG(id) AS task_ids + FROM tasks + WHERE project_id = :project_id + AND task_status = :bad_imagery_status + GROUP BY mapped_by + ), + validated AS ( + SELECT + validated_by AS user_id, + COUNT(validated_by) AS count, + ARRAY_AGG(id) AS task_ids + FROM tasks + WHERE project_id = :project_id + GROUP BY validated_by + ), + project_contributions AS ( + SELECT DISTINCT user_id + FROM task_history + WHERE project_id = :project_id + AND action != 'COMMENT' + ) + SELECT + u.id, + u.username, + u.name, + u.mapping_level, + u.picture_url, + u.date_registered, + COALESCE(m.count, 0) AS mapped, + COALESCE(v.count, 0) AS validated, + COALESCE(b.count, 0) AS bad_imagery, + COALESCE(m.count, 0) + COALESCE(v.count, 0) + COALESCE(b.count, 0) AS total, + COALESCE(m.task_ids, '{}') AS mapped_tasks, + COALESCE(v.task_ids, '{}') AS validated_tasks, + COALESCE(b.task_ids, '{}') AS bad_imagery_tasks + FROM users u + JOIN project_contributions pc ON u.id = pc.user_id + LEFT JOIN mapped m ON u.id = m.user_id + LEFT JOIN badimagery b ON u.id = b.user_id + LEFT JOIN validated v ON u.id = v.user_id + ORDER BY total DESC; + """ + + # Execute the query + rows = await db.fetch_all( + query, + values={ + "project_id": project_id, + "bad_imagery_status": TaskStatus.BADIMAGERY.value, + }, ) + # Process the results into DTO contrib_dto = ProjectContributionsDTO() user_contributions = [ UserContribution( dict( - username=r.username, - name=r.name, - mapping_level=MappingLevel(r.mapping_level).name, - picture_url=r.picture_url, - mapped=r.mapped, - bad_imagery=r.bad_imagery, - validated=r.validated, - total=r.total, - mapped_tasks=r.mapped_tasks if r.mapped_tasks is not None else [], - bad_imagery_tasks=r.bad_imagery_tasks - if r.bad_imagery_tasks - else [], - validated_tasks=r.validated_tasks - if r.validated_tasks is not None - else [], - date_registered=r.date_registered.date(), + username=row["username"], + name=row["name"], + mapping_level=MappingLevel(row["mapping_level"]).name, + picture_url=row["picture_url"], + mapped=row["mapped"], + bad_imagery=row["bad_imagery"], + validated=row["validated"], + total=row["total"], + mapped_tasks=( + row["mapped_tasks"] if row["mapped_tasks"] is not None else [] + ), + bad_imagery_tasks=( + row["bad_imagery_tasks"] if row["bad_imagery_tasks"] else [] + ), + validated_tasks=( + row["validated_tasks"] + if row["validated_tasks"] is not None + else [] + ), + date_registered=( + row["date_registered"].date() + if isinstance(row["date_registered"], datetime.datetime) + else None + ), ) ) - for r in results + for row in rows ] contrib_dto.user_contributions = user_contributions return contrib_dto + def homepage_cache_key_builder(func, *args, **kwargs): + args_without_db = args[:-1] + return f"{func.__name__}:{args_without_db}:{kwargs}" + @staticmethod - @cached(homepage_stats_cache) - def get_homepage_stats(abbrev=True) -> HomePageStatsDTO: + @cached(cache=Cache.MEMORY, key_builder=homepage_cache_key_builder, ttl=600) + async def get_homepage_stats( + abbrev: bool = True, db: Database = None + ) -> HomePageStatsDTO: """Get overall TM stats to give community a feel for progress that's being made""" dto = HomePageStatsDTO() - dto.total_projects = Project.query.with_entities( - func.count(Project.id) - ).scalar() - dto.mappers_online = ( - Task.query.with_entities(func.count(Task.locked_by.distinct())) - .filter(Task.locked_by.isnot(None)) - .scalar() + # Total Projects + query = select(func.count(Project.id)) + dto.total_projects = await db.fetch_val(query) + + # Mappers online (distinct users who locked tasks) + query = select(func.count(Task.locked_by.distinct())).where( + Task.locked_by.isnot(None) ) - dto.total_mappers = User.query.with_entities(func.count(User.id)).scalar() - dto.tasks_mapped = ( - Task.query.with_entities(func.count()) - .filter( - Task.task_status.in_( - (TaskStatus.MAPPED.value, TaskStatus.VALIDATED.value) - ) - ) - .scalar() + dto.mappers_online = await db.fetch_val(query) + + # Total Mappers + query = select(func.count(User.id)) + dto.total_mappers = await db.fetch_val(query) + + # Tasks mapped (status: MAPPED, VALIDATED) + query = select(func.count()).where( + Task.task_status.in_([TaskStatus.MAPPED.value, TaskStatus.VALIDATED.value]) ) + dto.tasks_mapped = await db.fetch_val(query) + if not abbrev: - dto.total_validators = ( - Task.query.filter(Task.task_status == TaskStatus.VALIDATED.value) - .distinct(Task.validated_by) - .count() + # Total Validators + query = select(func.count(Task.validated_by.distinct())).where( + Task.task_status == TaskStatus.VALIDATED.value ) - dto.tasks_validated = Task.query.filter( + dto.total_validators = await db.fetch_val(query) + + # Tasks Validated + query = select(func.count()).where( Task.task_status == TaskStatus.VALIDATED.value - ).count() + ) + dto.tasks_validated = await db.fetch_val(query) - dto.total_area = Project.query.with_entities( + # Total Area (sum of project areas in km²) + query = select( func.coalesce(func.sum(func.ST_Area(Project.geometry, True) / 1000000)) - ).scalar() - - dto.total_mapped_area = ( - Task.query.with_entities( - func.coalesce(func.sum(func.ST_Area(Task.geometry, True) / 1000000)) - ) - .filter(Task.task_status == TaskStatus.MAPPED.value) - .scalar() ) + dto.total_area = await db.fetch_val(query) - dto.total_validated_area = ( - Task.query.with_entities( - func.coalesce(func.sum(func.ST_Area(Task.geometry, True) / 1000000)) - ) - .filter(Task.task_status == TaskStatus.VALIDATED.value) - .scalar() - ) + # Total Mapped Area + query = select( + func.coalesce(func.sum(func.ST_Area(Task.geometry, True) / 1000000)) + ).where(Task.task_status == TaskStatus.MAPPED.value) + dto.total_mapped_area = await db.fetch_val(query) - unique_campaigns = Campaign.query.with_entities( - func.count(Campaign.id) - ).scalar() + # Total Validated Area + query = select( + func.coalesce(func.sum(func.ST_Area(Task.geometry, True) / 1000000)) + ).where(Task.task_status == TaskStatus.VALIDATED.value) + dto.total_validated_area = await db.fetch_val(query) - linked_campaigns_count = ( - Campaign.query.join( - campaign_projects, Campaign.id == campaign_projects.c.campaign_id - ) - .with_entities( - Campaign.name, func.count(campaign_projects.c.campaign_id) - ) + # Campaign Stats + query = select(func.count(Campaign.id)) + unique_campaigns = await db.fetch_val(query) + + query = ( + select([Campaign.name, func.count()]) + .select_from(Campaign.join(campaign_projects)) .group_by(Campaign.id) - .all() ) + linked_campaigns_count = await db.fetch_all(query) + + subquery = select(campaign_projects.c.project_id.distinct()).subquery() + query = select(func.count()).where(~Project.id.in_(subquery)) + no_campaign_count = await db.fetch_val(query) - subquery = ( - db.session.query(campaign_projects.c.project_id.distinct()) - .order_by(campaign_projects.c.project_id) - .subquery() - ) - no_campaign_count = ( - Project.query.with_entities(func.count()) - .filter(~Project.id.in_(subquery)) - .scalar() - ) dto.campaigns = [CampaignStatsDTO(row) for row in linked_campaigns_count] if no_campaign_count: dto.campaigns.append( @@ -447,27 +522,22 @@ def get_homepage_stats(abbrev=True) -> HomePageStatsDTO: ) dto.total_campaigns = unique_campaigns - unique_orgs = Organisation.query.with_entities( - func.count(Organisation.id) - ).scalar() - linked_orgs_count = ( - db.session.query(Organisation.name, func.count(Project.organisation_id)) + # Organisation Stats + query = select(func.count(Organisation.id)) + unique_orgs = await db.fetch_val(query) + + query = ( + select([Organisation.name, func.count(Project.organisation_id)]) .join(Project.organisation) .group_by(Organisation.id) - .all() ) + linked_orgs_count = await db.fetch_all(query) + + subquery = select(Project.organisation_id.distinct()).subquery() + query = select(func.count()).where(~Organisation.id.in_(subquery)) + no_org_project_count = await db.fetch_val(query) - subquery = ( - db.session.query(Project.organisation_id.distinct()) - .order_by(Project.organisation_id) - .subquery() - ) - no_org_project_count = ( - Organisation.query.with_entities(func.count()) - .filter(~Organisation.id.in_(subquery)) - .scalar() - ) dto.organisations = [ OrganizationListStatsDTO(row) for row in linked_orgs_count ] @@ -499,207 +569,246 @@ def get_homepage_stats(abbrev=True) -> HomePageStatsDTO: return dto @staticmethod - def update_all_project_stats(): - projects = db.session.query(Project.id) - for project_id in projects.all(): - StatsService.update_project_stats(project_id) + async def update_all_project_stats(db: Database): + query = "SELECT id FROM projects" + project_ids = await db.fetch_all(query) + + for record in project_ids: + await StatsService.update_project_stats(record["id"]) @staticmethod - def update_project_stats(project_id: int): - project = ProjectService.get_project_by_id(project_id) - tasks = Task.query.filter(Task.project_id == project_id) - - project.total_tasks = tasks.count() - project.tasks_mapped = tasks.filter( - Task.task_status == TaskStatus.MAPPED.value - ).count() - project.tasks_validated = tasks.filter( - Task.task_status == TaskStatus.VALIDATED.value - ).count() - project.tasks_bad_imagery = tasks.filter( - Task.task_status == TaskStatus.BADIMAGERY.value - ).count() - project.save() + async def update_project_stats(project_id: int, db: Database): + # Fetch task counts + await db.execute( + """ + UPDATE projects + SET total_tasks = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id), + tasks_mapped = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 2), + tasks_validated = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 4), + tasks_bad_imagery = (SELECT COUNT(*) FROM tasks WHERE project_id = :project_id AND task_status = 6) + WHERE id = :project_id; + """, + {"project_id": project_id}, + ) + await db.execute( + """ + UPDATE users + SET projects_mapped = array_append(projects_mapped, :project_id) + WHERE id IN ( + SELECT DISTINCT user_id + FROM task_history + WHERE action = 'STATE_CHANGE' AND project_id = :project_id + ); + """, + {"project_id": project_id}, + ) @staticmethod - def get_all_users_statistics(start_date: date, end_date: date): - users = User.query.filter( - User.date_registered >= start_date, - User.date_registered <= end_date, + async def get_all_users_statistics(start_date: date, end_date: date, db: Database): + # Base query for users within the date range + base_query = select(User).filter( + User.date_registered >= start_date, User.date_registered <= end_date ) + # Execute total user count stats_dto = UserStatsDTO() - stats_dto.total = users.count() - stats_dto.beginner = users.filter( - User.mapping_level == MappingLevel.BEGINNER.value - ).count() - stats_dto.intermediate = users.filter( - User.mapping_level == MappingLevel.INTERMEDIATE.value - ).count() - stats_dto.advanced = users.filter( - User.mapping_level == MappingLevel.ADVANCED.value - ).count() - stats_dto.contributed = users.filter(User.projects_mapped.isnot(None)).count() - stats_dto.email_verified = users.filter( - User.is_email_verified.is_(True) - ).count() + total_count_query = select(func.count()).select_from(base_query.subquery()) + result = await db.execute(total_count_query) + stats_dto.total = result + + # Beginner count + beginner_count_query = select(func.count()).select_from( + base_query.filter( + User.mapping_level == MappingLevel.BEGINNER.value + ).subquery() + ) + result = await db.execute(beginner_count_query) + stats_dto.beginner = result + # Intermediate count + intermediate_count_query = select(func.count()).select_from( + base_query.filter( + User.mapping_level == MappingLevel.INTERMEDIATE.value + ).subquery() + ) + result = await db.execute(intermediate_count_query) + stats_dto.intermediate = result + + # Advanced count + advanced_count_query = select(func.count()).select_from( + base_query.filter( + User.mapping_level == MappingLevel.ADVANCED.value + ).subquery() + ) + result = await db.execute(advanced_count_query) + stats_dto.advanced = result + + # Contributed count (those with projects mapped) + contributed_count_query = select(func.count()).select_from( + base_query.filter(User.projects_mapped.isnot(None)).subquery() + ) + result = await db.execute(contributed_count_query) + stats_dto.contributed = result + + # Email verified count + email_verified_count_query = select(func.count()).select_from( + base_query.filter(User.is_email_verified.is_(True)).subquery() + ) + result = await db.execute(email_verified_count_query) + stats_dto.email_verified = result + + # Gender stats gender_stats = GenderStatsDTO() - gender_stats.male = users.filter(User.gender == UserGender.MALE.value).count() - gender_stats.female = users.filter( - User.gender == UserGender.FEMALE.value - ).count() - gender_stats.self_describe = users.filter( - User.gender == UserGender.SELF_DESCRIBE.value - ).count() - gender_stats.prefer_not = users.filter( - User.gender == UserGender.PREFER_NOT.value - ).count() + # Male count + male_count_query = select(func.count()).select_from( + base_query.filter(User.gender == UserGender.MALE.value).subquery() + ) + result = await db.execute(male_count_query) + gender_stats.male = result + + # Female count + female_count_query = select(func.count()).select_from( + base_query.filter(User.gender == UserGender.FEMALE.value).subquery() + ) + result = await db.execute(female_count_query) + gender_stats.female = result + + # Self-describe count + self_describe_count_query = select(func.count()).select_from( + base_query.filter(User.gender == UserGender.SELF_DESCRIBE.value).subquery() + ) + result = await db.execute(self_describe_count_query) + gender_stats.self_describe = result + + # Prefer not to say count + prefer_not_count_query = select(func.count()).select_from( + base_query.filter(User.gender == UserGender.PREFER_NOT.value).subquery() + ) + result = await db.execute(prefer_not_count_query) + gender_stats.prefer_not = result + + # Set gender stats in the stats_dto stats_dto.genders = gender_stats + return stats_dto @staticmethod - def set_task_stats(result_row): - date_dto = TaskStats( - { - "date": result_row[0], - "mapped": result_row[1], - "validated": result_row[2], - "bad_imagery": result_row[3], - } - ) - return date_dto + def set_task_stats(row): + return { + "date": row["date"], + "mapped": row["mapped"], + "validated": row["validated"], + "bad_imagery": row["bad_imagery"], + } + + def cache_key_builder(func, *args, **kwargs): + args_without_first = args[1:] + return f"{func.__name__}:{args_without_first}:{kwargs}" @staticmethod - def get_task_stats( - start_date, end_date, org_id, org_name, campaign, project_id, country + @cached(cache=Cache.MEMORY, key_builder=cache_key_builder, ttl=3600) + async def get_task_stats( + db: Database, + start_date, + end_date, + org_id=None, + org_name=None, + campaign=None, + project_id=None, + country=None, ): - """Creates tasks stats for a period using the TaskStatsDTO""" - - query = ( - db.session.query( - TaskHistory.task_id, - TaskHistory.project_id, - TaskHistory.action_text, - func.DATE(TaskHistory.action_date).label("day"), - ) - .distinct( - TaskHistory.project_id, TaskHistory.task_id, TaskHistory.action_text - ) - .filter( - TaskHistory.action == "STATE_CHANGE", - or_( - TaskHistory.action_text == "MAPPED", - TaskHistory.action_text == "VALIDATED", - TaskHistory.action_text == "BADIMAGERY", - ), - ) - .order_by( - TaskHistory.project_id, - TaskHistory.task_id, - TaskHistory.action_text, - TaskHistory.action_date, - ) - ) + """Creates task stats for a period using the TaskStatsDTO""" + # Base query components + base_query = """ + WITH filtered_projects AS ( + SELECT id FROM projects + WHERE 1 = 1 + {filters} + ), + aggregated_stats AS ( + SELECT + DATE(action_date) AS day, + action_text, + COUNT(*) AS count + FROM task_history + WHERE action = 'STATE_CHANGE' + AND action_text IN ('MAPPED', 'VALIDATED', 'BADIMAGERY') + AND project_id IN (SELECT id FROM filtered_projects) + GROUP BY DATE(action_date), action_text + ), + date_series AS ( + SELECT generate_series( + CAST(:start_date AS DATE), + CAST(:end_date AS DATE), + INTERVAL '1 day' + )::DATE AS date + ) + SELECT + TO_CHAR(ds.date, 'YYYY-MM-DD') AS date, -- Cast date to string + COALESCE(SUM(CASE WHEN ag.action_text = 'MAPPED' THEN ag.count END), 0) AS mapped, + COALESCE(SUM(CASE WHEN ag.action_text = 'VALIDATED' THEN ag.count END), 0) AS validated, + COALESCE(SUM(CASE WHEN ag.action_text = 'BADIMAGERY' THEN ag.count END), 0) AS bad_imagery + FROM date_series ds + LEFT JOIN aggregated_stats ag ON ds.date = ag.day + GROUP BY ds.date + HAVING + COALESCE(SUM(CASE WHEN ag.action_text = 'MAPPED' THEN ag.count END), 0) > 0 OR + COALESCE(SUM(CASE WHEN ag.action_text = 'VALIDATED' THEN ag.count END), 0) > 0 OR + COALESCE(SUM(CASE WHEN ag.action_text = 'BADIMAGERY' THEN ag.count END), 0) > 0 + ORDER BY ds.date; + """ + + filters = [] + values = {"start_date": start_date, "end_date": end_date} if org_id: - query = query.join(Project, Project.id == TaskHistory.project_id).filter( - Project.organisation_id == org_id - ) + filters.append("AND organisation_id = :org_id") + values["org_id"] = int(org_id) + if org_name: - try: - organisation_id = OrganisationService.get_organisation_by_name( - org_name - ).id - except NotFound: - organisation_id = None - query = query.join(Project, Project.id == TaskHistory.project_id).filter( - Project.organisation_id == organisation_id + filters.append( + """ + AND organisation_id = ( + SELECT id FROM organisations WHERE name = :org_name + ) + """ ) - if campaign: - try: - campaign_id = CampaignService.get_campaign_by_name(campaign).id - except NotFound: - campaign_id = None - query = query.join( - campaign_projects, - campaign_projects.c.project_id == TaskHistory.project_id, - ).filter(campaign_projects.c.campaign_id == campaign_id) - if project_id: - query = query.filter(TaskHistory.project_id.in_(project_id)) - if country: - # Unnest country column array. - sq = Project.query.with_entities( - Project.id, func.unnest(Project.country).label("country") - ).subquery() + values["org_name"] = org_name - query = query.filter(sq.c.country.ilike("%{}%".format(country))).filter( - TaskHistory.project_id == sq.c.id + if campaign: + filters.append( + """ + AND id IN ( + SELECT project_id FROM campaign_projects + WHERE campaign_id = ( + SELECT id FROM campaigns WHERE name = :campaign + ) + ) + """ ) + values["campaign"] = campaign - query = query.subquery() - - date_query = db.session.query( - func.DATE( - func.generate_series(start_date, end_date, timedelta(days=1)) - ).label("d_day") - ).subquery() + if project_id: + filters.append("AND id = ANY(:project_id)") + values["project_id"] = project_id - grouped_dates = ( - db.session.query( - date_query.c.d_day, - query.c.action_text, - func.count(query.c.action_text).label("cnt"), - ) - .join(date_query, date_query.c.d_day == query.c.day) - .group_by(date_query.c.d_day, query.c.action_text) - .order_by(date_query.c.d_day) - ).subquery() - - mapped = ( - db.session.query( - grouped_dates.c.d_day, grouped_dates.c.action_text, grouped_dates.c.cnt - ) - .select_from(grouped_dates) - .filter(grouped_dates.c.action_text == "MAPPED") - .subquery() - ) - validated = ( - db.session.query( - grouped_dates.c.d_day, grouped_dates.c.action_text, grouped_dates.c.cnt - ) - .select_from(grouped_dates) - .filter(grouped_dates.c.action_text == "VALIDATED") - .subquery() - ) - badimagery = ( - db.session.query( - grouped_dates.c.d_day, grouped_dates.c.action_text, grouped_dates.c.cnt + if country: + filters.append( + """ + AND EXISTS ( + SELECT 1 + FROM unnest(country) AS c + WHERE c ILIKE :country + ) + """ ) - .select_from(grouped_dates) - .filter(grouped_dates.c.action_text == "BADIMAGERY") - .subquery() - ) + values["country"] = f"%{country}%" - result = ( - db.session.query( - func.to_char(grouped_dates.c.d_day, "YYYY-MM-DD"), - func.coalesce(mapped.c.cnt, 0).label("mapped"), - func.coalesce(validated.c.cnt, 0).label("validated"), - func.coalesce(badimagery.c.cnt, 0).label("badimagery"), - ) - .select_from(grouped_dates) - .distinct(grouped_dates.c.d_day) - .filter(grouped_dates.c.d_day is not None) - .outerjoin(mapped, mapped.c.d_day == grouped_dates.c.d_day) - .outerjoin(validated, validated.c.d_day == grouped_dates.c.d_day) - .outerjoin(badimagery, badimagery.c.d_day == grouped_dates.c.d_day) - ) + final_query = base_query.format(filters=" ".join(filters)) - day_stats_dto = list(map(StatsService.set_task_stats, result)) + results = await db.fetch_all(query=final_query, values=values) - results_dto = TaskStatsDTO() - results_dto.stats = day_stats_dto + stats_dicts = [dict(row) for row in results] - return results_dto + return TaskStatsDTO(stats=stats_dicts) diff --git a/backend/services/tags_service.py b/backend/services/tags_service.py index 07e8c34e12..80d664ac4b 100644 --- a/backend/services/tags_service.py +++ b/backend/services/tags_service.py @@ -3,6 +3,6 @@ class TagsService: @staticmethod - def get_all_countries(): + async def get_all_countries(db): """Get all countries""" - return Project.get_all_countries() + return await Project.get_all_countries(db) diff --git a/backend/services/task_annotations_service.py b/backend/services/task_annotations_service.py index 83582693b2..e8828f39d2 100644 --- a/backend/services/task_annotations_service.py +++ b/backend/services/task_annotations_service.py @@ -1,33 +1,65 @@ -from backend.models.postgis.task_annotation import TaskAnnotation -from backend.models.postgis.utils import timestamp +import datetime +import json + +from databases import Database class TaskAnnotationsService: @staticmethod - def add_or_update_annotation(annotation, project_id, annotation_type): - """Takes a json of tasks and create annotations in the db""" + async def add_or_update_annotation( + annotation, project_id, annotation_type, db: Database + ): + """Takes a JSON of tasks and creates or updates annotations in the database.""" task_id = annotation["taskId"] - source = annotation.get("annotationSource", None) - markdown = annotation.get("annotationMarkdown", None) - task_annotation = TaskAnnotation( - task_id, - project_id, - annotation_type, - annotation["properties"], - source, - markdown, - ) + source = annotation.get("annotationSource") + markdown = annotation.get("annotationMarkdown") + properties = json.dumps(annotation["properties"]) + updated_timestamp = datetime.datetime.utcnow() - # check if the task has this annotation_type - existing_annotation = TaskAnnotation.get_task_annotation( - task_id, project_id, annotation_type + query = """ + SELECT id FROM task_annotations + WHERE task_id = :task_id AND project_id = :project_id AND annotation_type = :annotation_type + """ + existing_annotation = await db.fetch_one( + query, + values={ + "task_id": task_id, + "project_id": project_id, + "annotation_type": annotation_type, + }, ) + if existing_annotation: - # update this annotation - existing_annotation.properties = task_annotation.properties - existing_annotation.updated_timestamp = timestamp() - existing_annotation.update() + update_query = """ + UPDATE task_annotations + SET properties = :properties, updated_timestamp = :updated_timestamp + WHERE id = :id + """ + await db.execute( + update_query, + values={ + "properties": properties, + "updated_timestamp": updated_timestamp, + "id": existing_annotation["id"], + }, + ) else: - # add this annotation - task_annotation.create() + insert_query = """ + INSERT INTO task_annotations + (task_id, project_id, annotation_type, properties, annotation_source, + annotation_markdown, updated_timestamp) + VALUES (:task_id, :project_id, :annotation_type, :properties, :source, :markdown, :updated_timestamp) + """ + await db.execute( + insert_query, + values={ + "task_id": task_id, + "project_id": project_id, + "annotation_type": annotation_type, + "properties": properties, + "source": source, + "markdown": markdown, + "updated_timestamp": updated_timestamp, + }, + ) diff --git a/backend/services/team_service.py b/backend/services/team_service.py index 6eeaf7b5b3..0df4abee2a 100644 --- a/backend/services/team_service.py +++ b/backend/services/team_service.py @@ -1,64 +1,72 @@ -from flask import current_app -from sqlalchemy import and_, or_ +from databases import Database +from fastapi.responses import JSONResponse +from loguru import logger from markdown import markdown -from backend import create_app, db +from backend.db import db_connection from backend.exceptions import NotFound +from backend.models.dtos.message_dto import MessageDTO +from backend.models.dtos.stats_dto import Pagination from backend.models.dtos.team_dto import ( - TeamDTO, NewTeamDTO, - TeamsListDTO, ProjectTeamDTO, TeamDetailsDTO, + TeamDTO, TeamSearchDTO, + TeamsListDTO, ) - -from backend.models.dtos.message_dto import MessageDTO -from backend.models.dtos.stats_dto import Pagination from backend.models.postgis.message import Message, MessageType -from backend.models.postgis.team import Team, TeamMembers from backend.models.postgis.project import ProjectTeams -from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.statuses import ( TeamJoinMethod, TeamMemberFunctions, - TeamVisibility, TeamRoles, + TeamVisibility, UserRole, ) +from backend.models.postgis.team import Team, TeamMembers +from backend.services.messaging.message_service import MessageService from backend.services.organisation_service import OrganisationService from backend.services.users.user_service import UserService -from backend.services.messaging.message_service import MessageService class TeamServiceError(Exception): """Custom Exception to notify callers an error occurred when handling teams""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class TeamJoinNotAllowed(Exception): """Custom Exception to notify bad user level on joining team""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class TeamService: @staticmethod - def request_to_join_team(team_id: int, user_id: int): - team = TeamService.get_team_by_id(team_id) + async def get_team_by_id_user(team_id: int, user_id: int, db: Database): + query = """ + SELECT * FROM team_members + WHERE team_id = :team_id AND user_id = :user_id + """ + team_member = await db.fetch_one( + query, values={"team_id": team_id, "user_id": user_id} + ) + return team_member + + @staticmethod + async def request_to_join_team(team_id: int, user_id: int, db: Database): + team = await TeamService.get_team_by_id(team_id, db) # If user has team manager permission add directly to the team without request.E.G. Admins, Org managers - if TeamService.is_user_team_member(team_id, user_id): + if await TeamService.is_user_team_member(team_id, user_id, db): raise TeamServiceError( "The user is already a member of the team or has requested to join." ) - if TeamService.is_user_team_manager(team_id, user_id): - TeamService.add_team_member( - team_id, user_id, TeamMemberFunctions.MEMBER.value, True + if await TeamService.is_user_team_manager(team_id, user_id, db): + await TeamService.add_team_member( + team_id, user_id, TeamMemberFunctions.MEMBER.value, True, db ) return @@ -69,39 +77,49 @@ def request_to_join_team(team_id: int, user_id: int): ) role = TeamMemberFunctions.MEMBER.value - user = UserService.get_user_by_id(user_id) + user = await UserService.get_user_by_id(user_id, db) active = False # Set active=True for team with join method ANY as no approval is required to join this team type. if team.join_method == TeamJoinMethod.ANY.value: active = True - TeamService.add_team_member(team_id, user_id, role, active) - + await TeamService.add_team_member(team_id, user_id, role, active, db) # Notify team managers about a join request in BY_REQUEST team. if team.join_method == TeamJoinMethod.BY_REQUEST.value: - team_managers = team.get_team_managers() + team_managers = await Team.get_team_managers(db, team.id) for manager in team_managers: - # Only send notifications to team managers who have join request notification enabled. if manager.join_request_notifications: - MessageService.send_request_to_join_team( - user.id, user.username, manager.user_id, team.name, team_id + manager_obj = await UserService.get_user_by_username( + manager.username, db + ) + await MessageService.send_request_to_join_team( + user.id, user.username, manager_obj.id, team.name, team_id, db ) @staticmethod - def add_user_to_team( - team_id: int, requesting_user: int, username: str, role: str = None + async def add_user_to_team( + team_id: int, + requesting_user: int, + username: str, + role: str = None, + db: Database = None, ): - is_manager = TeamService.is_user_team_manager(team_id, requesting_user) + is_manager = await TeamService.is_user_team_manager( + team_id, requesting_user, db + ) if not is_manager: raise TeamServiceError("User is not allowed to add member to the team") - team = TeamService.get_team_by_id(team_id) - from_user = UserService.get_user_by_id(requesting_user) - to_user = UserService.get_user_by_username(username) - member = TeamMembers.get(team_id, to_user.id) + team = await TeamService.get_team_by_id(team_id, db) + from_user = await UserService.get_user_by_id(requesting_user, db) + to_user = await UserService.get_user_by_username(username, db) + member = await TeamMembers.get(team_id, to_user.id, db) if member: member.function = TeamMemberFunctions[role].value member.active = True - member.update() - return {"Success": "User role updated"} + await TeamMembers.update(member, db) + return JSONResponse( + content={"Success": "User role updated"}, status_code=200 + ) + else: if role: try: @@ -110,62 +128,68 @@ def add_user_to_team( raise Exception("Invalid TeamMemberFunction") else: role = TeamMemberFunctions.MEMBER.value - TeamService.add_team_member(team_id, to_user.id, role, True) - MessageService.send_team_join_notification( + await TeamService.add_team_member(team_id, to_user.id, role, True, db) + await MessageService.send_team_join_notification( requesting_user, from_user.username, to_user.id, team.name, team_id, TeamMemberFunctions(role).name, + db, ) @staticmethod - def add_team_member(team_id, user_id, function, active=False): + async def add_team_member( + team_id, user_id, function, active=False, db: Database = None + ): team_member = TeamMembers() team_member.team_id = team_id team_member.user_id = user_id team_member.function = function team_member.active = active - team_member.create() + await TeamMembers.create(team_member, db) @staticmethod - def send_invite(team_id, from_user_id, username): - to_user = UserService.get_user_by_username(username) - from_user = UserService.get_user_by_id(from_user_id) - team = TeamService.get_team_by_id(team_id) + async def send_invite(team_id, from_user_id, username, db: Database): + to_user = await UserService.get_user_by_username(username, db) + from_user = await UserService.get_user_by_id(from_user_id, db) + team = await TeamService.get_team_by_id(team_id, db) MessageService.send_invite_to_join_team( from_user_id, from_user.username, to_user.id, team.name, team_id ) @staticmethod - def accept_reject_join_request(team_id, from_user_id, username, function, action): - from_user = UserService.get_user_by_id(from_user_id) - to_user_id = UserService.get_user_by_username(username).id - team = TeamService.get_team_by_id(team_id) + async def accept_reject_join_request( + team_id, from_user_id, username, function, action, db: Database + ): + from_user = await UserService.get_user_by_id(from_user_id, db) + user = await UserService.get_user_by_username(username, db) + to_user_id = user.id + team = await TeamService.get_team_by_id(team_id, db) - if not TeamService.is_user_team_member(team_id, to_user_id): + if not await TeamService.is_user_team_member(team_id, to_user_id, db): raise NotFound(sub_code="JOIN_REQUEST_NOT_FOUND", username=username) if action not in ["accept", "reject"]: raise TeamServiceError("Invalid action type") if action == "accept": - TeamService.activate_team_member(team_id, to_user_id) + await TeamService.activate_team_member(team_id, to_user_id, db) elif action == "reject": - TeamService.delete_invite(team_id, to_user_id) + await TeamService.delete_invite(team_id, to_user_id, db) - MessageService.accept_reject_request_to_join_team( - from_user_id, from_user.username, to_user_id, team.name, team_id, action + await MessageService.accept_reject_request_to_join_team( + from_user_id, from_user.username, to_user_id, team.name, team_id, action, db ) @staticmethod - def accept_reject_invitation_request( - team_id, from_user_id, username, function, action + async def accept_reject_invitation_request( + team_id, from_user_id, username, function, action, db: Database ): - from_user = UserService.get_user_by_id(from_user_id) - to_user = UserService.get_user_by_username(username) - team = TeamService.get_team_by_id(team_id) - team_members = team.get_team_managers() + from_user = await UserService.get_user_by_id(from_user_id, db) + to_user = await UserService.get_user_by_username(username, db) + team = await TeamService.get_team_by_id(team_id, db) + team_members = await Team.get_team_managers(db, team.id) for member in team_members: MessageService.accept_reject_invitation_request_for_team( @@ -178,260 +202,361 @@ def accept_reject_invitation_request( action, ) if action == "accept": - TeamService.add_team_member( - team_id, from_user_id, TeamMemberFunctions[function.upper()].value + await TeamService.add_team_member( + team_id, from_user_id, TeamMemberFunctions[function.upper()].value, db ) @staticmethod - def leave_team(team_id, username): - user = UserService.get_user_by_username(username) - team_member = TeamMembers.query.filter( - TeamMembers.team_id == team_id, TeamMembers.user_id == user.id - ).one_or_none() + async def leave_team(team_id, username, db: Database = None): + user = await UserService.get_user_by_username(username, db) + team_member = await TeamService.get_team_by_id_user(team_id, user.id, db) + + # Raise an exception if the team member is not found if not team_member: raise NotFound( sub_code="USER_NOT_IN_TEAM", username=username, team_id=team_id ) - team_member.delete() + + # If found, delete the team member + delete_query = """ + DELETE FROM team_members + WHERE team_id = :team_id AND user_id = :user_id + """ + await db.execute(delete_query, values={"team_id": team_id, "user_id": user.id}) @staticmethod - def add_team_project(team_id, project_id, role): + async def add_team_project(team_id, project_id, role, db: Database): team_project = ProjectTeams() team_project.project_id = project_id team_project.team_id = team_id team_project.role = TeamRoles[role].value - team_project.create() + await ProjectTeams.create(team_project, db) @staticmethod - def delete_team_project(team_id, project_id): - project = ProjectTeams.query.filter( - and_(ProjectTeams.team_id == team_id, ProjectTeams.project_id == project_id) - ).one() - project.delete() + async def delete_team_project(team_id: int, project_id: int, db: Database): + """ + Deletes a project team by team_id and project_id. + :param team_id: ID of the team + :param project_id: ID of the project + :param db: async database connection + """ + # Query to find the project team + query = """ + SELECT * FROM project_teams + WHERE team_id = :team_id AND project_id = :project_id + """ + project_team = await db.fetch_one( + query, values={"team_id": team_id, "project_id": project_id} + ) + + # Check if the project team exists + if not project_team: + raise NotFound( + sub_code="PROJECT_TEAM_NOT_FOUND", + team_id=team_id, + project_id=project_id, + ) + + # If found, delete the project team + delete_query = """ + DELETE FROM project_teams + WHERE team_id = :team_id AND project_id = :project_id + """ + await db.execute( + delete_query, values={"team_id": team_id, "project_id": project_id} + ) @staticmethod - def get_all_teams(search_dto: TeamSearchDTO) -> TeamsListDTO: - query = db.session.query(Team) + async def get_all_teams(search_dto: TeamSearchDTO, db: Database) -> TeamsListDTO: + query_parts = [] + params = {} + + base_query = """ + SELECT t.id, t.name, t.join_method, t.visibility, t.description, + o.logo, o.name as organisation_name, o.id as organisation_id + FROM teams t + JOIN organisations o ON t.organisation_id = o.id + """ - orgs_query = None - user = UserService.get_user_by_id(search_dto.user_id) - is_admin = UserRole(user.role) == UserRole.ADMIN if search_dto.organisation: - orgs_query = query.filter(Team.organisation_id == search_dto.organisation) - if search_dto.manager and search_dto.manager == search_dto.user_id: - manager_teams = query.filter( - TeamMembers.user_id == search_dto.manager, - TeamMembers.active == True, # noqa - TeamMembers.function == TeamMemberFunctions.MANAGER.value, - Team.id == TeamMembers.team_id, - ) - - manager_orgs_teams = query.filter( - Team.organisation_id.in_( - [ - org.id - for org in OrganisationService.get_organisations( - search_dto.manager - ) - ] + query_parts.append("t.organisation_id = :organisation_id") + params["organisation_id"] = search_dto.organisation + + if search_dto.manager and int(search_dto.manager) == int(search_dto.user_id): + manager_teams_query = """ + SELECT t.id FROM teams t + JOIN team_members tm ON t.id = tm.team_id + WHERE tm.user_id = :manager_id AND tm.active = true AND tm.function = :manager_function + """ + params["manager_id"] = int(search_dto.manager) + params["manager_function"] = TeamMemberFunctions.MANAGER.value + + orgs_teams_query = """ + SELECT t.id FROM teams t + WHERE t.organisation_id = ANY( + SELECT organisation_id FROM organisation_managers WHERE user_id = :manager_id ) - ) + """ - query = manager_teams.union(manager_orgs_teams) + query_parts.append( + f"t.id IN ({manager_teams_query} UNION {orgs_teams_query})" + ) if search_dto.team_name: - query = query.filter( - Team.name.ilike("%" + search_dto.team_name + "%"), - ) + query_parts.append("t.name ILIKE :team_name") + params["team_name"] = f"%{search_dto.team_name}%" if search_dto.team_role: try: role = TeamRoles[search_dto.team_role.upper()].value - project_teams = ( - db.session.query(ProjectTeams) - .filter(ProjectTeams.role == role) - .subquery() - ) - query = query.join(project_teams) + project_teams_query = """ + SELECT pt.team_id FROM project_teams pt WHERE pt.role = :team_role + """ + query_parts.append(f"t.id IN ({project_teams_query})") + params["team_role"] = role except KeyError: pass if search_dto.member: - team_member = ( - db.session.query(TeamMembers) - .filter( - TeamMembers.user_id == search_dto.member, - TeamMembers.active.is_(True), - ) - .subquery() - ) - query = query.join(team_member) + team_member_query = """ + SELECT tm.team_id FROM team_members tm + WHERE tm.user_id = :member_id AND tm.active = true + """ + query_parts.append(f"t.id IN ({team_member_query})") + params["member_id"] = search_dto.member if search_dto.member_request: - team_member = ( - db.session.query(TeamMembers) - .filter( - TeamMembers.user_id == search_dto.member_request, - TeamMembers.active.is_(False), - ) - .subquery() - ) - query = query.join(team_member) - if orgs_query: - query = query.union(orgs_query) - - # Only show public teams and teams that the user is a member of + team_member_request_query = """ + SELECT tm.team_id FROM team_members tm + WHERE tm.user_id = :member_request_id AND tm.active = false + """ + query_parts.append(f"t.id IN ({team_member_request_query})") + params["member_request_id"] = search_dto.member_request + + user = await UserService.get_user_by_id(search_dto.user_id, db) + is_admin = UserRole(user.role) == UserRole.ADMIN if not is_admin: - query = query.filter( - or_( - Team.visibility == TeamVisibility.PUBLIC.value, - # Since user.teams returns TeamMembers, we need to get the team_id - Team.id.in_([team.team_id for team in user.teams]), + public_or_member_query = """ + t.visibility = :public_visibility OR t.id IN ( + SELECT tm.team_id FROM team_members tm WHERE tm.user_id = :user_id ) - ) - teams_list_dto = TeamsListDTO() + """ + query_parts.append(f"({public_or_member_query})") + params["public_visibility"] = TeamVisibility.PUBLIC.value + params["user_id"] = search_dto.user_id + + if query_parts: + final_query = f"{base_query} WHERE {' AND '.join(query_parts)}" + else: + final_query = base_query if search_dto.paginate: - paginated = query.paginate( - page=search_dto.page, per_page=search_dto.per_page, error_out=True - ) - teams_list_dto.pagination = Pagination(paginated) - teams_list = paginated.items + final_query_paginated = final_query + limit = search_dto.per_page + offset = (search_dto.page - 1) * search_dto.per_page + final_query_paginated += f" LIMIT {limit} OFFSET {offset}" + rows = await db.fetch_all(query=final_query_paginated, values=params) + else: - teams_list = query.all() - for team in teams_list: - team_dto = TeamDTO() - team_dto.team_id = team.id - team_dto.name = team.name - team_dto.join_method = TeamJoinMethod(team.join_method).name - team_dto.visibility = TeamVisibility(team.visibility).name - team_dto.description = team.description - team_dto.logo = team.organisation.logo - team_dto.organisation = team.organisation.name - team_dto.organisation_id = team.organisation.id - team_dto.members = [] - # Skip if members are not included + rows = await db.fetch_all(query=final_query, values=params) + + teams_list_dto = TeamsListDTO() + for row in rows: + team_dto = TeamDTO( + team_id=row["id"], + name=row["name"], + join_method=TeamJoinMethod(row["join_method"]).name, + visibility=TeamVisibility(row["visibility"]).name, + description=row["description"], + logo=row["logo"], + organisation=row["organisation_name"], + organisation_id=row["organisation_id"], + members=[], + ) + if not search_dto.omit_members: if search_dto.full_members_list: - team_members = team.members + team_dto.members = await Team.get_all_members(db, row["id"], None) else: - team_managers = team.get_team_managers(10) - team_members = team.get_team_members(10) + team_managers = await Team.get_team_managers(db, row["id"], 10) + team_members = await Team.get_team_members(db, row["id"], 10) team_members.extend(team_managers) - team_dto.members = [ - team.as_dto_team_member(member) for member in team_members - ] - team_dto.members_count = team.get_members_count_by_role( - TeamMemberFunctions.MEMBER + team_dto.members = team_members + + team_dto.members_count = await Team.get_members_count_by_role( + db, row["id"], TeamMemberFunctions.MEMBER ) - team_dto.managers_count = team.get_members_count_by_role( - TeamMemberFunctions.MANAGER + team_dto.managers_count = await Team.get_members_count_by_role( + db, row["id"], TeamMemberFunctions.MANAGER ) + teams_list_dto.teams.append(team_dto) + + if search_dto.paginate: + total_query = "SELECT COUNT(*) FROM (" + final_query + ") as total" + total = await db.fetch_val(query=total_query, values=params) + teams_list_dto.pagination = Pagination.from_total_count( + total=total, page=search_dto.page, per_page=search_dto.per_page + ) return teams_list_dto - @staticmethod - def get_team_as_dto( - team_id: int, user_id: int, abbreviated: bool + async def get_team_as_dto( + team_id: int, user_id: int, abbreviated: bool, db: Database ) -> TeamDetailsDTO: - team = TeamService.get_team_by_id(team_id) + # Query to fetch team and organisation details + team_query = """ + SELECT t.id as team_id, t.name as team_name, t.join_method, t.visibility, + t.description, o.logo as org_logo, o.name as org_name, + o.id as org_id, o.slug as org_slug + FROM teams t + JOIN organisations o ON t.organisation_id = o.id + WHERE t.id = :team_id + """ - if team is None: + # Fetch the team details + team_details = await db.fetch_one(query=team_query, values={"team_id": team_id}) + + if not team_details: raise NotFound(sub_code="TEAM_NOT_FOUND", team_id=team_id) - team_dto = TeamDetailsDTO() - team_dto.team_id = team.id - team_dto.name = team.name - team_dto.join_method = TeamJoinMethod(team.join_method).name - team_dto.visibility = TeamVisibility(team.visibility).name - team_dto.description = team.description - team_dto.logo = team.organisation.logo - team_dto.organisation = team.organisation.name - team_dto.organisation_id = team.organisation.id - team_dto.organisation_slug = team.organisation.slug + # Create the TeamDetailsDTO + team_dto = TeamDetailsDTO( + team_id=team_details["team_id"], + name=team_details["team_name"], + join_method=TeamJoinMethod(team_details["join_method"]).name, + visibility=TeamVisibility(team_details["visibility"]).name, + description=team_details["description"], + logo=team_details["org_logo"], + organisation=team_details["org_name"], + organisation_id=team_details["org_id"], + organisation_slug=team_details["org_slug"], + ) + # Check for admin roles if user_id is provided if user_id != 0: - if UserService.is_user_an_admin(user_id): - team_dto.is_general_admin = True - - if OrganisationService.is_user_an_org_manager( - team.organisation.id, user_id - ): - team_dto.is_org_admin = True - else: - team_dto.is_general_admin = False - team_dto.is_org_admin = False + team_dto.is_general_admin = await UserService.is_user_an_admin(user_id, db) + team_dto.is_org_admin = await OrganisationService.is_user_an_org_manager( + team_details["org_id"], user_id, db + ) if abbreviated: return team_dto - team_dto.members = [team.as_dto_team_member(member) for member in team.members] - - team_projects = TeamService.get_projects_by_team_id(team.id) - - team_dto.team_projects = [ - team.as_dto_team_project(project) for project in team_projects - ] - + # Fetch and add team members to the DTO + members_query = """ + SELECT user_id FROM team_members WHERE team_id = :team_id + """ + members = await db.fetch_all(query=members_query, values={"team_id": team_id}) + team_dto.members = ( + [ + await Team.as_dto_team_member(member.user_id, team_id, db) + for member in members + ] + if members + else [] + ) + team_projects = await TeamService.get_projects_by_team_id(team_id, db) + team_dto.team_projects = ( + [Team.as_dto_team_project(project) for project in team_projects] + if team_projects + else [] + ) return team_dto @staticmethod - def get_projects_by_team_id(team_id: int): - projects = ( - db.session.query( - ProjectInfo.name, ProjectTeams.project_id, ProjectTeams.role - ) - .join(ProjectTeams, ProjectInfo.project_id == ProjectTeams.project_id) - .filter(ProjectTeams.team_id == team_id) - .all() - ) + async def get_projects_by_team_id(team_id: int, db: Database): + # SQL query to fetch project details associated with the team + projects_query = """ + SELECT p.name, pt.project_id, pt.role + FROM project_teams pt + JOIN project_info p ON p.project_id = pt.project_id + WHERE pt.team_id = :team_id + """ + + # Execute the query and fetch all results + projects = await db.fetch_all(query=projects_query, values={"team_id": team_id}) - if projects is None: - raise NotFound(sub_code="PROJECTS_NOT_FOUND", team_id=team_id) + if not projects: + projects = [] return projects @staticmethod - def get_project_teams_as_dto(project_id: int) -> TeamsListDTO: - """Gets all the teams for a specified project""" - project_teams = ProjectTeams.query.filter( - ProjectTeams.project_id == project_id - ).all() + async def get_project_teams_as_dto(project_id: int, db: Database) -> TeamsListDTO: + """Gets all the teams for a specified project with their roles and names""" + # Raw SQL query to get project teams with team names + query = """ + SELECT pt.team_id, t.name AS team_name, pt.role + FROM project_teams pt + JOIN teams t ON pt.team_id = t.id + WHERE pt.project_id = :project_id + """ + project_teams = await db.fetch_all( + query=query, values={"project_id": project_id} + ) + # Initialize the DTO teams_list_dto = TeamsListDTO() + # Populate the DTO with team data for project_team in project_teams: - team = TeamService.get_team_by_id(project_team.team_id) - team_dto = ProjectTeamDTO() - team_dto.team_id = project_team.team_id - team_dto.team_name = team.name - team_dto.role = project_team.role - + team_dto = ProjectTeamDTO( + team_id=project_team["team_id"], + team_name=project_team["team_name"], + role=str(project_team["role"]), + ) teams_list_dto.teams.append(team_dto) return teams_list_dto @staticmethod - def change_team_role(team_id: int, project_id: int, role: str): - project = ProjectTeams.query.filter( - and_(ProjectTeams.team_id == team_id, ProjectTeams.project_id == project_id) - ).one() - project.role = TeamRoles[role].value - project.save() + async def change_team_role(team_id: int, project_id: int, role: str, db: Database): + """ + Change the role of a team in a project. + :param team_id: ID of the team + :param project_id: ID of the project + :param role: New role to assign + :param db: Database instance for executing queries + """ + # Assuming `TeamRoles[role].value` gives the correct integer or string value for the role + new_role_value = TeamRoles[role].value + + # Write the raw SQL query to update the role in the `project_teams` table + query = """ + UPDATE project_teams + SET role = :new_role_value + WHERE team_id = :team_id AND project_id = :project_id + """ + + # Execute the query + await db.execute( + query, + { + "new_role_value": new_role_value, + "team_id": team_id, + "project_id": project_id, + }, + ) @staticmethod - def get_team_by_id(team_id: int) -> Team: + async def get_team_by_id(team_id: int, db: Database): """ Get team from DB :param team_id: ID of team to fetch :returns: Team :raises: Not Found """ - team = Team.get(team_id) - - if team is None: + # Raw SQL query to select the team by ID + query = """ + SELECT id, name, organisation_id, join_method, description, visibility + FROM teams + WHERE id = :team_id + """ + # Execute the query and fetch the team + team_record = await db.fetch_one(query=query, values={"team_id": team_id}) + if team_record is None: raise NotFound(sub_code="TEAM_NOT_FOUND", team_id=team_id) - return team + return team_record @staticmethod def get_team_by_name(team_name: str) -> Team: @@ -443,34 +568,34 @@ def get_team_by_name(team_name: str) -> Team: return team @staticmethod - def create_team(new_team_dto: NewTeamDTO) -> int: + async def create_team(new_team_dto: NewTeamDTO, db: Database) -> int: """ Creates a new team using a team dto :param new_team_dto: Team DTO :returns: ID of new Team """ - TeamService.assert_validate_organisation(new_team_dto.organisation_id) + await TeamService.assert_validate_organisation(new_team_dto.organisation_id, db) - team = Team.create_from_dto(new_team_dto) - return team.id + team = await Team.create_from_dto(new_team_dto, db) + return team @staticmethod - def update_team(team_dto: TeamDTO) -> Team: + async def update_team(team_dto: TeamDTO, db: Database) -> Team: """ Updates a team :param team_dto: DTO with updated info :returns updated Team """ - team = TeamService.get_team_by_id(team_dto.team_id) - team.update(team_dto) + team = await TeamService.get_team_by_id(team_dto.team_id, db) + team = await Team.update(team, team_dto, db) - return team + return team["id"] if team else None @staticmethod - def assert_validate_organisation(org_id: int): + async def assert_validate_organisation(org_id: int, db: Database): """Makes sure an organisation exists""" try: - OrganisationService.get_organisation_by_id(org_id) + await OrganisationService.get_organisation_by_id(org_id, db) except NotFound: raise TeamServiceError(f"Organisation {org_id} does not exist") @@ -498,61 +623,126 @@ def assert_validate_members(team_dto: TeamDTO): team_dto.members = members @staticmethod - def _get_team_members(team_id: int): - return TeamMembers.query.filter_by(team_id=team_id).all() + async def _get_team_members(team_id: int, db: Database): + # Asynchronous query to fetch team members by team_id + query = "SELECT * FROM team_members WHERE team_id = :team_id" + return await db.fetch_all(query, values={"team_id": team_id}) @staticmethod - def _get_active_team_members(team_id: int): - return TeamMembers.query.filter_by(team_id=team_id, active=True).all() + async def _get_active_team_members(team_id: int, db: Database): + try: + query = """ + SELECT * FROM team_members + WHERE team_id = :team_id AND active = TRUE + """ + return await db.fetch_all(query, values={"team_id": team_id}) + except Exception as e: + print(f"Error executing query: {str(e)}") + raise @staticmethod - def activate_team_member(team_id: int, user_id: int): - member = TeamMembers.query.filter( - TeamMembers.team_id == team_id, TeamMembers.user_id == user_id - ).first() - member.active = True - db.session.add(member) - db.session.commit() + async def activate_team_member(team_id: int, user_id: int, db: Database): + # Fetch the member by team_id and user_id + member = await TeamService.get_team_by_id_user(team_id, user_id, db) + + if member: + # Update the 'active' status of the member + update_query = """ + UPDATE team_members + SET active = TRUE + WHERE team_id = :team_id AND user_id = :user_id + """ + await db.execute( + update_query, values={"team_id": team_id, "user_id": user_id} + ) + else: + # Handle case where member is not found + raise ValueError( + f"No member found with team_id {team_id} and user_id {user_id}" + ) @staticmethod - def delete_invite(team_id: int, user_id: int): - member = TeamMembers.query.filter( - TeamMembers.team_id == team_id, TeamMembers.user_id == user_id - ).first() - member.delete() + async def delete_invite(team_id: int, user_id: int, db: Database): + # Fetch the member by team_id and user_id to check if it exists + member = await TeamService.get_team_by_id_user(team_id, user_id, db) + + if member: + # Delete the member from the database + delete_query = """ + DELETE FROM team_members + WHERE team_id = :team_id AND user_id = :user_id + """ + await db.execute( + delete_query, values={"team_id": team_id, "user_id": user_id} + ) + else: + # Handle case where member is not found + raise ValueError( + f"No member found with team_id {team_id} and user_id {user_id}" + ) @staticmethod - def is_user_team_member(team_id: int, user_id: int): - query = TeamMembers.query.filter( - TeamMembers.team_id == team_id, - TeamMembers.user_id == user_id, - ).exists() - return db.session.query(query).scalar() + async def is_user_team_member(team_id: int, user_id: int, db: Database) -> bool: + # Query to check if the user is a member of the team + query = """ + SELECT EXISTS ( + SELECT 1 FROM team_members + WHERE team_id = :team_id AND user_id = :user_id + ) AS is_member + """ + result = await db.fetch_one( + query, values={"team_id": team_id, "user_id": user_id} + ) + + # The result contains the 'is_member' field, which is a boolean + return result["is_member"] @staticmethod - def is_user_an_active_team_member(team_id: int, user_id: int): - query = TeamMembers.query.filter( - TeamMembers.team_id == team_id, - TeamMembers.user_id == user_id, - TeamMembers.active.is_(True), - ).exists() - return db.session.query(query).scalar() + async def is_user_an_active_team_member( + team_id: int, user_id: int, db: Database + ) -> bool: + """ + Check if a user is an active member of a team. + :param team_id: ID of the team + :param user_id: ID of the user + :param db: Database connection + :returns: True if the user is an active member, False otherwise + """ + # Raw SQL query to check if the user is an active team member + query = """ + SELECT EXISTS( + SELECT 1 + FROM team_members + WHERE team_id = :team_id + AND user_id = :user_id + AND active = true + ) AS is_active + """ + + # Execute the query and fetch the result + result = await db.fetch_one( + query=query, values={"team_id": team_id, "user_id": user_id} + ) + # Return the boolean value indicating if the user is an active team member + return result["is_active"] @staticmethod - def is_user_team_manager(team_id: int, user_id: int): + async def is_user_team_manager(team_id: int, user_id: int, db: Database) -> bool: # Admin manages all teams - team = TeamService.get_team_by_id(team_id) - if UserService.is_user_an_admin(user_id): + team = await TeamService.get_team_by_id(team_id, db) + if await UserService.is_user_an_admin(user_id, db): return True - managers = team.get_team_managers() + managers = await Team.get_team_managers(db, team.id) for member in managers: - if member.user_id == user_id: + team_manager = await UserService.get_user_by_username(member.username, db) + if team_manager.id == user_id: return True # Org admin manages teams attached to their org user_managed_orgs = [ - org.id for org in OrganisationService.get_organisations(user_id) + org.organisation_id + for org in await OrganisationService.get_organisations(user_id, db) ] if team.organisation_id in user_managed_orgs: return True @@ -560,63 +750,70 @@ def is_user_team_manager(team_id: int, user_id: int): return False @staticmethod - def delete_team(team_id: int): + async def delete_team(team_id: int, db: Database): """Deletes a team""" - team = TeamService.get_team_by_id(team_id) - - if team.can_be_deleted(): - team.delete() - return {"Success": "Team deleted"}, 200 + team = await TeamService.get_team_by_id(team_id, db) + if await Team.can_be_deleted(team_id, db): + await Team.delete(team, db) + return JSONResponse(content={"Success": "Team deleted"}, status_code=200) else: - return { - "Error": "Team has projects, cannot be deleted", - "SubCode": "This team has projects associated. Before deleting team, unlink any associated projects.", - }, 400 + return JSONResponse( + content={ + "Error": "Team has projects, cannot be deleted", + "SubCode": "This team has projects associated. Before deleting, unlink any associated projects.", + }, + status_code=400, + ) @staticmethod - def check_team_membership(project_id: int, allowed_roles: list, user_id: int): + async def check_team_membership( + project_id: int, allowed_roles: list, user_id: int, db + ): """Given a project and permitted team roles, check user's membership in the team list""" - teams_dto = TeamService.get_project_teams_as_dto(project_id) + teams_dto = await TeamService.get_project_teams_as_dto(project_id, db) teams_allowed = [ - team_dto for team_dto in teams_dto.teams if team_dto.role in allowed_roles + team_dto + for team_dto in teams_dto.teams + if int(team_dto.role) in allowed_roles ] user_membership = [ team_dto.team_id for team_dto in teams_allowed - if TeamService.is_user_an_active_team_member(team_dto.team_id, user_id) + if await TeamService.is_user_an_active_team_member( + team_dto.team_id, user_id, db + ) ] return len(user_membership) > 0 @staticmethod - def send_message_to_all_team_members( - team_id: int, team_name: str, message_dto: MessageDTO + async def send_message_to_all_team_members( + team_id: int, + team_name: str, + message_dto: MessageDTO, + user_id: int, ): - """Sends supplied message to all contributors in a team. Message all team members can take - over a minute to run, so this method is expected to be called on its own thread - """ - app = ( - create_app() - ) # Because message-all run on background thread it needs it's own app context - - with app.app_context(): - team_members = TeamService._get_active_team_members(team_id) - sender = UserService.get_user_by_id(message_dto.from_user_id).username - - message_dto.message = ( - "A message from {}, manager of {} team:

{}".format( - MessageService.get_user_profile_link(sender), - MessageService.get_team_link(team_name, team_id, False), - markdown(message_dto.message, output_format="html"), + try: + async with db_connection.database.connection() as conn: + team_members = await TeamService._get_active_team_members(team_id, conn) + user = await UserService.get_user_by_id(user_id, conn) + sender = user.username + message_dto.message = ( + "A message from {}, manager of {} team:

{}".format( + MessageService.get_user_profile_link(sender), + MessageService.get_team_link(team_name, team_id, False), + markdown(message_dto.message, output_format="html"), + ) ) - ) - - messages = [] - for team_member in team_members: - if team_member.user_id != message_dto.from_user_id: - message = Message.from_dto(team_member.user_id, message_dto) - message.message_type = MessageType.TEAM_BROADCAST.value - message.save() - user = UserService.get_user_by_id(team_member.user_id) - messages.append(dict(message=message, user=user)) - - MessageService._push_messages(messages) + messages = [] + for team_member in team_members: + if team_member.user_id != user_id: + message = Message.from_dto(team_member.user_id, message_dto) + message.message_type = MessageType.TEAM_BROADCAST.value + user = await UserService.get_user_by_id( + team_member.user_id, conn + ) + messages.append(dict(message=message, user=user)) + await MessageService._push_messages(messages, conn) + logger.info("Messages sent successfully.") + except Exception as e: + logger.error(f"Error sending messages in background task: {str(e)}") diff --git a/backend/services/users/authentication_service.py b/backend/services/users/authentication_service.py index 93fbd3852b..1a8bd41775 100644 --- a/backend/services/users/authentication_service.py +++ b/backend/services/users/authentication_service.py @@ -1,16 +1,32 @@ import base64 +import binascii import urllib.parse - -from flask import current_app, request -from flask_httpauth import HTTPTokenAuth -from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from random import SystemRandom +from typing import Optional + +from databases import Database +from fastapi import Depends, HTTPException, Security, status +from fastapi.responses import JSONResponse +from fastapi.security.api_key import APIKeyHeader +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from loguru import logger +from starlette.authentication import ( + AuthCredentials, + AuthenticationBackend, + AuthenticationError, + SimpleUser, +) from backend.api.utils import TMAPIDecorators +from backend.config import settings +from backend.db import get_db +from backend.models.dtos.user_dto import AuthUserDTO +from backend.models.postgis.statuses import UserRole +from backend.models.postgis.user import User from backend.services.messaging.message_service import MessageService -from backend.services.users.user_service import UserService, NotFound -from random import SystemRandom +from backend.services.users.user_service import NotFound, UserService -token_auth = HTTPTokenAuth(scheme="Token") +# token_auth = HTTPTokenAuth(scheme="Token") tm = TMAPIDecorators() UNICODE_ASCII_CHARACTER_SET = ( @@ -18,13 +34,16 @@ ) -@token_auth.error_handler +# @token_auth.error_handler def handle_unauthorized_token(): - current_app.logger.debug("Token not valid") - return {"Error": "Token is expired or invalid", "SubCode": "InvalidToken"}, 401 + logger.debug("Token not valid") + return JSONResponse( + content={"Error": "Token is expired or invalid", "SubCode": "InvalidToken"}, + status_code=401, + ) -@token_auth.verify_token +# @token_auth.verify_token def verify_token(token): """Verify the supplied token and check user role is correct for the requested resource""" tm.authenticated_user_id = None @@ -34,12 +53,12 @@ def verify_token(token): try: decoded_token = base64.b64decode(token).decode("utf-8") except UnicodeDecodeError: - current_app.logger.debug(f"Unable to decode token {request.base_url}") + logger.debug("Unable to decode token") return False # Can't decode token, so fail login - valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) + valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 120) if not valid_token: - current_app.logger.debug(f"Token not valid {request.base_url}") + logger.debug("Token not valid") return False tm.authenticated_user_id = ( @@ -48,17 +67,44 @@ def verify_token(token): return user_id # All tests passed token is good for the requested resource +class TokenAuthBackend(AuthenticationBackend): + async def authenticate(self, conn): + if "authorization" not in conn.headers: + return + + auth = conn.headers["authorization"] + try: + scheme, credentials = auth.split() + if scheme.lower() != "token": + return + try: + decoded_token = base64.b64decode(credentials).decode("ascii") + except UnicodeDecodeError: + logger.debug("Unable to decode token") + return False + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid auth credentials") + + valid_token, user_id = AuthenticationService.is_valid_token( + decoded_token, 604800 + ) + if not valid_token: + logger.debug("Token not valid.") + return + tm.authenticated_user_id = user_id + return AuthCredentials(["authenticated"]), SimpleUser(user_id) + + class AuthServiceError(Exception): """Custom Exception to notify callers an error occurred when authenticating""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class AuthenticationService: @staticmethod - def login_user(osm_user_details, email, user_element="user") -> dict: + async def login_user(osm_user_details, email, db, user_element="user") -> dict: """ Generates authentication details for user, creating in DB if user is unknown to us :param osm_user_details: XML response from OSM @@ -82,16 +128,17 @@ def login_user(osm_user_details, email, user_element="user") -> dict: user_picture = None try: - UserService.get_user_by_id(osm_id) - UserService.update_user(osm_id, username, user_picture) + await UserService.get_user_by_id(osm_id, db) + await UserService.update_user(osm_id, username, user_picture, db) except NotFound: # User not found, so must be new user changesets = osm_user.get("changesets") changeset_count = int(changesets.get("count")) - new_user = UserService.register_user( - osm_id, username, changeset_count, user_picture, email - ) - MessageService.send_welcome_message(new_user) + async with db.transaction(): + new_user = await UserService.register_user( + osm_id, username, changeset_count, user_picture, email, db + ) + await MessageService.send_welcome_message(new_user, db) session_token = AuthenticationService.generate_session_token_for_user(osm_id) return { @@ -101,9 +148,9 @@ def login_user(osm_user_details, email, user_element="user") -> dict: } @staticmethod - def authenticate_email_token(username: str, token: str): + async def authenticate_email_token(username: str, token: str, db: Database): """Validate that the email token is valid""" - user = UserService.get_user_by_username(username) + user = await UserService.get_user_by_username(username, db) is_valid, tokenised_email = AuthenticationService.is_valid_token(token, 86400) @@ -116,13 +163,13 @@ def authenticate_email_token(username: str, token: str): raise AuthServiceError("InvalidEmail- Email address does not match token") # Token is valid so update DB and return - user.set_email_verified_status(is_verified=True) + await User.set_email_verified_status(user, is_verified=True, db=db) return AuthenticationService._get_email_validated_url(True) @staticmethod def _get_email_validated_url(is_valid: bool) -> str: """Helper function to generate redirect url for email verification""" - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL verification_params = {"is_valid": is_valid} verification_url = "{0}/validate-email?{1}".format( @@ -133,7 +180,7 @@ def _get_email_validated_url(is_valid: bool) -> str: @staticmethod def get_authentication_failed_url(): """Generates the auth-failed URL for the running app""" - base_url = current_app.config["APP_BASE_URL"] + base_url = settings.APP_BASE_URL auth_failed_url = f"{base_url}/auth-failed" return auth_failed_url @@ -144,7 +191,7 @@ def generate_session_token_for_user(osm_id: int): :param osm_id: OSM ID of the user authenticating :return: Token """ - entropy = current_app.secret_key if current_app.secret_key else "un1testingmode" + entropy = settings.SECRET_KEY if settings.SECRET_KEY else "un1testingmode" serializer = URLSafeTimedSerializer(entropy) return serializer.dumps(osm_id) @@ -169,16 +216,104 @@ def is_valid_token(token, token_expiry): :param token_expiry: When the token expires in seconds :return: True if token is valid, and user_id contained in token """ - entropy = current_app.secret_key if current_app.secret_key else "un1testingmode" + entropy = settings.SECRET_KEY if settings.SECRET_KEY else "un1testingmode" serializer = URLSafeTimedSerializer(entropy) try: tokenised_user_id = serializer.loads(token, max_age=token_expiry) except SignatureExpired: - current_app.logger.debug("Token has expired") + # current_app.logger.debug("Token has expired") return False, "ExpiredToken- Token has expired" except BadSignature: - current_app.logger.debug("Bad Token Signature") + # current_app.logger.debug("Bad Token Signature") return False, "BadSignature- Bad Token Signature" return True, tokenised_user_id + + +async def login_required( + Authorization: str = Security(APIKeyHeader(name="Authorization")), +): + if not Authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + try: + scheme, credentials = Authorization.split() + if scheme.lower() != "token": + raise HTTPException(status_code=401, detail="Invalid authentication scheme") + try: + decoded_token = base64.b64decode(credentials).decode("ascii") + except UnicodeDecodeError: + logger.debug("Unable to decode token") + raise HTTPException(status_code=401, detail="Invalid token") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid auth credentials") + valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) + if not valid_token: + logger.debug("Token not valid") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"Error": "Token is expired or invalid", "SubCode": "InvalidToken"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + return AuthUserDTO(id=user_id) + + +async def login_required_optional( + Authorization: Optional[str] = Security( + APIKeyHeader(name="Authorization", auto_error=False) + ), +): + if not Authorization: + return None + try: + scheme, credentials = Authorization.split() + if scheme.lower() != "token": + raise HTTPException(status_code=401, detail="Invalid authentication scheme") + try: + decoded_token = base64.b64decode(credentials).decode("ascii") + except UnicodeDecodeError: + logger.debug("Unable to decode token") + raise HTTPException(status_code=401, detail="Invalid token") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid auth credentials") + valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) + if not valid_token: + logger.debug("Token not valid") + return None + return AuthUserDTO(id=user_id) + + +async def pm_only( + Authorization: str = Security(APIKeyHeader(name="Authorization")), + db: Database = Depends(get_db), +): + if not Authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + try: + scheme, credentials = Authorization.split() + if scheme.lower() != "token": + raise HTTPException(status_code=401, detail="Invalid authentication scheme") + try: + decoded_token = base64.b64decode(credentials).decode("ascii") + except UnicodeDecodeError: + raise HTTPException(status_code=401, detail="Invalid token") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise HTTPException(status_code=401, detail="Invalid auth credentials") + + valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) + if not valid_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"Error": "Token is expired or invalid", "SubCode": "InvalidToken"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + query = "SELECT id, username, role FROM users WHERE id = :user_id" + user = await db.fetch_one(query=query, values={"user_id": user_id}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user["role"] != UserRole.ADMIN.value: + raise HTTPException(status_code=403, detail="Admin access required") + + return AuthUserDTO(id=user["id"]) diff --git a/backend/services/users/osm_service.py b/backend/services/users/osm_service.py index 33feb7ae17..7b1b044982 100644 --- a/backend/services/users/osm_service.py +++ b/backend/services/users/osm_service.py @@ -1,6 +1,7 @@ import requests -from flask import current_app +from loguru import logger +from backend.config import settings from backend.models.dtos.user_dto import UserOSMDTO @@ -8,8 +9,7 @@ class OSMServiceError(Exception): """Custom Exception to notify callers an error occurred when in the User Service""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class OSMService: @@ -20,9 +20,7 @@ def get_osm_details_for_user(user_id: int) -> UserOSMDTO: :param user_id: user_id in scope :raises OSMServiceError """ - osm_user_details_url = ( - f"{current_app.config['OSM_SERVER_URL']}/api/0.6/user/{user_id}.json" - ) + osm_user_details_url = f"{settings.OSM_SERVER_URL}/api/0.6/user/{user_id}.json" response = requests.get(osm_user_details_url) if response.status_code != 200: diff --git a/backend/services/users/user_service.py b/backend/services/users/user_service.py index 8324cd1477..bdb7cfbb29 100644 --- a/backend/services/users/user_service.py +++ b/backend/services/users/user_service.py @@ -1,65 +1,62 @@ -from cachetools import TTLCache, cached -from flask import current_app import datetime -from sqlalchemy.sql.expression import literal -from sqlalchemy import func, or_, desc, and_, distinct, cast, Time, column +from databases import Database +from loguru import logger +from sqlalchemy import and_, desc, distinct, func, insert, select + +from backend.config import Settings from backend.exceptions import NotFound -from backend import db +from backend.models.dtos.interests_dto import InterestDTO, InterestsListDTO from backend.models.dtos.project_dto import ProjectFavoritesDTO, ProjectSearchResultsDTO +from backend.models.dtos.stats_dto import Pagination from backend.models.dtos.user_dto import ( + UserContributionDTO, + UserCountriesContributed, + UserCountryContributed, UserDTO, - UserOSMDTO, UserFilterDTO, - UserSearchQuery, + UserOSMDTO, + UserRegisterEmailDTO, UserSearchDTO, + UserSearchQuery, UserStatsDTO, - UserContributionDTO, - UserRegisterEmailDTO, - UserCountryContributed, - UserCountriesContributed, + UserTaskDTOs, ) -from backend.models.dtos.interests_dto import InterestsListDTO, InterestDTO from backend.models.postgis.interests import Interest, project_interests -from backend.models.postgis.message import Message, MessageType +from backend.models.postgis.message import MessageType from backend.models.postgis.project import Project -from backend.models.postgis.user import User, UserRole, MappingLevel, UserEmail -from backend.models.postgis.task import TaskHistory, TaskAction, Task -from backend.models.dtos.user_dto import UserTaskDTOs -from backend.models.dtos.stats_dto import Pagination -from backend.models.postgis.statuses import TaskStatus, ProjectStatus -from backend.services.users.osm_service import OSMService, OSMServiceError +from backend.models.postgis.statuses import ProjectStatus, TaskStatus +from backend.models.postgis.task import Task, TaskHistory +from backend.models.postgis.user import MappingLevel, User, UserEmail, UserRole +from backend.models.postgis.utils import timestamp from backend.services.messaging.smtp_service import SMTPService from backend.services.messaging.template_service import ( get_txt_template, template_var_replacing, ) +from backend.services.users.osm_service import OSMService, OSMServiceError - -user_filter_cache = TTLCache(maxsize=1024, ttl=600) +settings = Settings() class UserServiceError(Exception): """Custom Exception to notify callers an error occurred when in the User Service""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class UserService: @staticmethod - def get_user_by_id(user_id: int) -> User: - user = User.get_by_id(user_id) - + async def get_user_by_id(user_id: int, db: Database) -> User: + user = await User.get_by_id(user_id, db) if user is None: raise NotFound(sub_code="USER_NOT_FOUND", user_id=user_id) - return user @staticmethod - def get_user_by_username(username: str) -> User: - user = User.get_by_username(username) + async def get_user_by_username(username: str, db: Database) -> User: + user = await User.get_by_username(username, db) if user is None: raise NotFound(sub_code="USER_NOT_FOUND", username=username) @@ -67,71 +64,85 @@ def get_user_by_username(username: str) -> User: return user @staticmethod - def get_contributions_by_day(user_id: int): - # Validate that user exists. - stats = ( - TaskHistory.query.with_entities( - func.DATE(TaskHistory.action_date).label("day"), - func.count(TaskHistory.action).label("cnt"), - ) - .filter(TaskHistory.user_id == user_id) - .filter(TaskHistory.action == TaskAction.STATE_CHANGE.name) - .filter( - func.DATE(TaskHistory.action_date) - > datetime.date.today() - datetime.timedelta(days=365) - ) - .group_by("day") - .order_by(desc("day")) - ) + async def get_contributions_by_day(user_id: int, db: Database): + # Define the query using raw SQL + query = """ + SELECT + DATE(action_date) AS day, + COUNT(action) AS cnt + FROM task_history + WHERE user_id = :user_id + AND action = 'STATE_CHANGE' + AND DATE(action_date) > CURRENT_DATE - INTERVAL '1 year' + GROUP BY day + ORDER BY day DESC; + """ + results = await db.fetch_all(query=query, values={"user_id": user_id}) contributions = [ - UserContributionDTO(dict(date=str(s[0]), count=s[1])) for s in stats + UserContributionDTO(date=record["day"], count=record["cnt"]) + for record in results ] return contributions @staticmethod - def get_project_managers() -> User: - users = User.query.filter(User.role == 2).all() + async def get_project_managers(db: Database): + query = "SELECT * FROM users WHERE role = :role" + users = await db.fetch_all(query, values={"role": 2}) - if users is None: + if not users: raise NotFound(sub_code="USER_NOT_FOUND") return users @staticmethod - def get_general_admins() -> User: - users = User.query.filter(User.role == 1).all() + async def get_general_admins(db: Database): + query = "SELECT * FROM users WHERE role = :role" + users = await db.fetch_all(query, values={"role": 1}) - if users is None: + if not users: raise NotFound(sub_code="USER_NOT_FOUND") return users @staticmethod - def update_user(user_id: int, osm_username: str, picture_url: str) -> User: - user = UserService.get_user_by_id(user_id) + async def update_user( + user_id: int, osm_username: str, picture_url: str, db: Database + ) -> User: + user = await UserService.get_user_by_id(user_id, db) if user.username != osm_username: - user.update_username(osm_username) + await user.update_username(osm_username, db) if user.picture_url != picture_url: - user.update_picture_url(picture_url) + await user.update_picture_url(picture_url, db) return user @staticmethod - def get_projects_favorited(user_id: int) -> ProjectFavoritesDTO: - user = UserService.get_user_by_id(user_id) - projects_dto = [f.as_dto_for_admin(f.id) for f in user.favorites] + async def get_projects_favorited(user_id: int, db: Database) -> ProjectFavoritesDTO: + # Query to get the project IDs favorited by the user + project_ids_query = """ + SELECT project_id + FROM project_favorites + WHERE user_id = :user_id + """ + project_ids_rows = await db.fetch_all(project_ids_query, {"user_id": user_id}) + if not project_ids_rows: + return ProjectFavoritesDTO(favorited_projects=[]) + + projects_dto = [ + await Project.as_dto_for_admin(row["project_id"], db) + for row in project_ids_rows + ] fav_dto = ProjectFavoritesDTO() fav_dto.favorited_projects = projects_dto - return fav_dto @staticmethod - def get_projects_mapped(user_id: int): - user = UserService.get_user_by_id(user_id) + async def get_projects_mapped(user_id: int, db: Database): + user = await UserService.get_user_by_id(user_id, db) projects_mapped = user.projects_mapped # Return empty list if the user has no projects_mapped. @@ -141,66 +152,101 @@ def get_projects_mapped(user_id: int): return projects_mapped @staticmethod - def register_user(osm_id, username, changeset_count, picture_url, email): + async def register_user(osm_id, username, changeset_count, picture_url, email, db): """ Creates user in DB :param osm_id: Unique OSM user id :param username: OSM Username :param changeset_count: OSM changeset count """ - new_user = User() - new_user.id = osm_id - new_user.username = username - if picture_url is not None: - new_user.picture_url = picture_url - - intermediate_level = current_app.config["MAPPER_LEVEL_INTERMEDIATE"] - advanced_level = current_app.config["MAPPER_LEVEL_ADVANCED"] + """ + Creates user in DB + :param osm_id: Unique OSM user id + :param username: OSM Username + :param changeset_count: OSM changeset count + """ + # Determine mapping level based on changeset count + intermediate_level = settings.MAPPER_LEVEL_INTERMEDIATE + advanced_level = settings.MAPPER_LEVEL_ADVANCED if changeset_count > advanced_level: - new_user.mapping_level = MappingLevel.ADVANCED.value - elif intermediate_level < changeset_count < advanced_level: - new_user.mapping_level = MappingLevel.INTERMEDIATE.value + mapping_level = MappingLevel.ADVANCED.value + elif intermediate_level < changeset_count <= advanced_level: + mapping_level = MappingLevel.INTERMEDIATE.value else: - new_user.mapping_level = MappingLevel.BEGINNER.value - - if email is not None: - new_user.email_address = email - - new_user.create() + mapping_level = MappingLevel.BEGINNER.value + + values = { + "id": osm_id, + "username": username, + "role": 0, + "mapping_level": mapping_level, + "tasks_mapped": 0, + "tasks_validated": 0, + "tasks_invalidated": 0, + "projects_mapped": [], + "email_address": email, + "is_email_verified": False, + "is_expert": False, + "picture_url": picture_url, + "default_editor": "ID", + "mentions_notifications": True, + "projects_comments_notifications": False, + "projects_notifications": True, + "tasks_notifications": True, + "tasks_comments_notifications": False, + "teams_announcement_notifications": True, + "date_registered": datetime.datetime.utcnow(), + } + + query = insert(User).values(values) + await db.execute(query) + + user_query = select(User).where(User.id == osm_id) + new_user = await db.fetch_one(user_query) return new_user @staticmethod - def get_user_dto_by_username( - requested_username: str, logged_in_user_id: int + async def get_user_dto_by_username( + requested_username: str, logged_in_user_id: int, db: Database ) -> UserDTO: """Gets user DTO for supplied username""" - requested_user = UserService.get_user_by_username(requested_username) - logged_in_user = UserService.get_user_by_id(logged_in_user_id) - UserService.check_and_update_mapper_level(requested_user.id) + query = """ + SELECT * FROM users + WHERE username = :username + """ + result = await db.fetch_one(query, values={"username": requested_username}) + if result is None: + raise NotFound(sub_code="USER_NOT_FOUND", username=requested_username) + requested_user = User(**result) + logged_in_user = await UserService.get_user_by_id(logged_in_user_id, db) + await UserService.check_and_update_mapper_level(requested_user.id, db) return requested_user.as_dto(logged_in_user.username) @staticmethod - def get_user_dto_by_id(user: int, request_user: int) -> UserDTO: + async def get_user_dto_by_id( + user_id: int, request_user: int, db: Database + ) -> UserDTO: """Gets user DTO for supplied user id""" - user = UserService.get_user_by_id(user) + user = await UserService.get_user_by_id(user_id, db) if request_user: - request_username = UserService.get_user_by_id(request_user).username - return user.as_dto(request_username) + request_user = await UserService.get_user_by_id(request_user, db) + return user.as_dto(request_user.username) return user.as_dto() @staticmethod - def get_interests_stats(user_id): + async def get_interests_stats(user_id: int, db: Database): # Get all projects that the user has contributed. stmt = ( - TaskHistory.query.with_entities(TaskHistory.project_id) + select(TaskHistory.project_id) .distinct() - .filter(TaskHistory.user_id == user_id) + .where(TaskHistory.user_id == user_id) ) - interests = ( - Interest.query.with_entities( + # Prepare the query for interests + interests_query = ( + select( Interest.id, Interest.name, func.count(distinct(project_interests.c.project_id)).label( @@ -216,297 +262,307 @@ def get_interests_stats(user_id): ) .group_by(Interest.id) .order_by(desc("count_projects")) - .all() ) - interests_dto = [ - InterestDTO(dict(id=i.id, name=i.name, count_projects=i.count_projects)) - for i in interests - ] + # Execute the query + interests = await db.fetch_all(interests_query) + + # Map results to DTOs + interests_dto = [InterestDTO(**i) for i in interests] return interests_dto @staticmethod - def get_tasks_dto( + async def get_tasks_dto( user_id: int, start_date: datetime.datetime = None, end_date: datetime.datetime = None, task_status: str = None, project_status: str = None, project_id: int = None, - page=1, - page_size=10, + page: int = 1, + page_size: int = 10, sort_by: str = None, + db: Database = None, ) -> UserTaskDTOs: + # Base query to get the latest task history actions for a user base_query = ( - TaskHistory.query.with_entities( + select( TaskHistory.project_id.label("project_id"), TaskHistory.task_id.label("task_id"), func.max(TaskHistory.action_date).label("max"), ) - .filter(TaskHistory.user_id == user_id) + .where(TaskHistory.user_id == user_id) .group_by(TaskHistory.task_id, TaskHistory.project_id) ) if task_status: - base_query = base_query.filter( + base_query = base_query.where( TaskHistory.action_text == TaskStatus[task_status.upper()].name ) if start_date: - base_query = base_query.filter(TaskHistory.action_date >= start_date) + base_query = base_query.where(TaskHistory.action_date >= start_date) if end_date: - base_query = base_query.filter(TaskHistory.action_date <= end_date) + base_query = base_query.where(TaskHistory.action_date <= end_date) - user_task_dtos = UserTaskDTOs() - task_id_list = base_query.subquery() + task_id_list = base_query.alias("task_id_list") + # Query to get the number of comments per task comments_query = ( - TaskHistory.query.with_entities( + select( TaskHistory.project_id, TaskHistory.task_id, func.count(TaskHistory.action).label("count"), ) - .filter(TaskHistory.action == "COMMENT") + .where(TaskHistory.action == "COMMENT") .group_by(TaskHistory.task_id, TaskHistory.project_id) - ).subquery() + ).alias("comments_query") + # Subquery for joining comments and task IDs sq = ( - db.session.query( - func.coalesce(comments_query.c.count, 0).label("comments"), task_id_list + select( + func.coalesce(comments_query.c.count, 0).label("comments"), + task_id_list.c.project_id, + task_id_list.c.task_id, + task_id_list.c.max, ) .select_from(task_id_list) .outerjoin( comments_query, - (comments_query.c.task_id == task_id_list.c.task_id) - & (comments_query.c.project_id == task_id_list.c.project_id), + and_( + comments_query.c.task_id == task_id_list.c.task_id, + comments_query.c.project_id == task_id_list.c.project_id, + ), ) - .subquery() - ) + ).alias("sq") - tasks = Task.query.join( - sq, - and_( - Task.id == sq.c.task_id, - Task.project_id == sq.c.project_id, - ), + # Main task query joining with subquery + tasks_query = ( + select(Task, sq.c.max, sq.c.comments) + .select_from(Task) + .join( + sq, + and_( + Task.id == sq.c.task_id, + Task.project_id == sq.c.project_id, + ), + ) ) - tasks = tasks.add_columns(column("max"), column("comments")) if sort_by == "action_date": - tasks = tasks.order_by(sq.c.max) + tasks_query = tasks_query.order_by(sq.c.max) elif sort_by == "-action_date": - tasks = tasks.order_by(desc(sq.c.max)) + tasks_query = tasks_query.order_by(desc(sq.c.max)) elif sort_by == "project_id": - tasks = tasks.order_by(sq.c.project_id) + tasks_query = tasks_query.order_by(sq.c.project_id) elif sort_by == "-project_id": - tasks = tasks.order_by(desc(sq.c.project_id)) + tasks_query = tasks_query.order_by(desc(sq.c.project_id)) if project_status: - tasks = tasks.filter( - Task.project_id == Project.id, - Project.status == ProjectStatus[project_status.upper()].value, + tasks_query = tasks_query.where( + and_( + Task.project_id == Project.id, + Project.status == ProjectStatus[project_status.upper()].value, + ) ) if project_id: - tasks = tasks.filter_by(project_id=project_id) - - results = tasks.paginate(page=page, per_page=page_size, error_out=True) - - task_list = [] - - for task, action_date, comments in results.items: - task_list.append(task.as_dto(last_updated=action_date, comments=comments)) + tasks_query = tasks_query.where(Task.project_id == project_id) + # Pagination + offset = (page - 1) * page_size + paginated_tasks_query = tasks_query.limit(page_size).offset(offset) + + # Execute the query and fetch results + all_tasks = await db.fetch_all(tasks_query) + paginated_tasks = await db.fetch_all(paginated_tasks_query) + + # Create list of task DTOs from the results + task_list = [ + await Task.task_as_dto( + row, last_updated=row["max"], comments=row["comments"], db=db + ) + for row in paginated_tasks + ] + user_task_dtos = UserTaskDTOs() user_task_dtos.user_tasks = task_list - user_task_dtos.pagination = Pagination(results) + user_task_dtos.pagination = Pagination.from_total_count( + page=int(page), per_page=int(page_size), total=len(all_tasks) + ) return user_task_dtos @staticmethod - def get_detailed_stats(username: str): - user = UserService.get_user_by_username(username) + async def get_detailed_stats(username: str, db: Database) -> UserStatsDTO: stats_dto = UserStatsDTO() - - actions = [ - TaskStatus.VALIDATED.name, - TaskStatus.INVALIDATED.name, - TaskStatus.MAPPED.name, - ] - - actions_table = ( - db.session.query(literal(TaskStatus.VALIDATED.name).label("action_text")) - .union( - db.session.query( - literal(TaskStatus.INVALIDATED.name).label("action_text") - ), - db.session.query(literal(TaskStatus.MAPPED.name).label("action_text")), - ) - .subquery() - .alias("actions_table") - ) - - # Get only rows with the given actions. - filtered_actions = ( - TaskHistory.query.with_entities( - TaskHistory.user_id, - TaskHistory.project_id, - TaskHistory.task_id, - TaskHistory.action_text, - ) - .filter(TaskHistory.action_text.in_(actions)) - .subquery() - .alias("filtered_actions") - ) - - user_tasks = ( - db.session.query(filtered_actions) - .filter(filtered_actions.c.user_id == user.id) - .distinct( - filtered_actions.c.project_id, - filtered_actions.c.task_id, - filtered_actions.c.action_text, + user_query = """ + SELECT id FROM users WHERE username = :username + """ + user = await db.fetch_one(query=user_query, values={"username": username}) + if not user: + raise NotFound(sub_code="USER_NOT_FOUND", username=username) + user_id = user["id"] + stats_query = """ + WITH user_actions AS ( + SELECT + action_text, + COUNT(DISTINCT (project_id, task_id)) AS action_count + FROM task_history + WHERE user_id = :user_id + AND action_text IN ('VALIDATED', 'INVALIDATED', 'MAPPED') + GROUP BY action_text + ), + others_actions AS ( + SELECT + action_text, + COUNT(DISTINCT (project_id, task_id)) AS action_count + FROM task_history th + WHERE (project_id, task_id) IN ( + SELECT project_id, task_id + FROM task_history + WHERE user_id = :user_id + AND action_text IN ('VALIDATED', 'INVALIDATED', 'MAPPED') + ) + AND user_id != :user_id + AND action_text IN ('VALIDATED', 'INVALIDATED') + GROUP BY action_text ) - .subquery() - .alias("user_tasks") + SELECT + CAST(COALESCE((SELECT SUM(action_count) FROM user_actions WHERE action_text = 'VALIDATED'), 0) + AS INTEGER) AS tasks_validated, + CAST(COALESCE((SELECT SUM(action_count) FROM user_actions WHERE action_text = 'INVALIDATED'), 0) + AS INTEGER) AS tasks_invalidated, + CAST(COALESCE((SELECT SUM(action_count) FROM user_actions WHERE action_text = 'MAPPED'), 0) + AS INTEGER) AS tasks_mapped, + CAST(COALESCE((SELECT SUM(action_count) FROM others_actions WHERE action_text = 'VALIDATED'), 0) + AS INTEGER) AS tasks_validated_by_others, + CAST(COALESCE((SELECT SUM(action_count) FROM others_actions WHERE action_text = 'INVALIDATED'), 0) + AS INTEGER) AS tasks_invalidated_by_others; + """ + stats_result = await db.fetch_one( + query=stats_query, values={"user_id": user_id} ) + stats_dto.tasks_mapped = stats_result["tasks_mapped"] + stats_dto.tasks_validated = stats_result["tasks_validated"] + stats_dto.tasks_invalidated = stats_result["tasks_invalidated"] + stats_dto.tasks_validated_by_others = stats_result["tasks_validated_by_others"] + stats_dto.tasks_invalidated_by_others = stats_result[ + "tasks_invalidated_by_others" + ] - others_tasks = ( - db.session.query(filtered_actions) - .filter(filtered_actions.c.user_id != user.id) - .filter(filtered_actions.c.task_id == user_tasks.c.task_id) - .filter(filtered_actions.c.project_id == user_tasks.c.project_id) - .filter(filtered_actions.c.action_text != TaskStatus.MAPPED.name) - .distinct( - filtered_actions.c.project_id, - filtered_actions.c.task_id, - filtered_actions.c.action_text, - ) - .subquery() - .alias("others_tasks") + projects_mapped_query = """ + SELECT COUNT(DISTINCT project_id) AS projects_count + FROM task_history + WHERE user_id = :user_id AND action_text = 'MAPPED'; + """ + projects_mapped = await db.fetch_one( + query=projects_mapped_query, values={"user_id": user_id} ) + stats_dto.projects_mapped = projects_mapped["projects_count"] - user_stats = ( - db.session.query( - actions_table.c.action_text, func.count(user_tasks.c.action_text) - ) - .outerjoin( - user_tasks, actions_table.c.action_text == user_tasks.c.action_text - ) - .group_by(actions_table.c.action_text) + stats_dto.countries_contributed = await UserService.get_countries_contributed( + user_id, db ) - others_stats = ( - db.session.query( - func.concat(actions_table.c.action_text, "_BY_OTHERS"), - func.count(others_tasks.c.action_text), - ) - .outerjoin( - others_tasks, actions_table.c.action_text == others_tasks.c.action_text - ) - .group_by(actions_table.c.action_text) + stats_dto.contributions_by_day = await UserService.get_contributions_by_day( + user_id, db ) - res = user_stats.union(others_stats).all() - results = {key: value for key, value in res} - - projects_mapped = UserService.get_projects_mapped(user.id) - stats_dto.tasks_mapped = results["MAPPED"] - stats_dto.tasks_validated = results["VALIDATED"] - stats_dto.tasks_invalidated = results["INVALIDATED"] - stats_dto.tasks_validated_by_others = results["VALIDATED_BY_OTHERS"] - stats_dto.tasks_invalidated_by_others = results["INVALIDATED_BY_OTHERS"] - stats_dto.projects_mapped = len(projects_mapped) - stats_dto.countries_contributed = UserService.get_countries_contributed(user.id) - stats_dto.contributions_by_day = UserService.get_contributions_by_day(user.id) stats_dto.total_time_spent = 0 stats_dto.time_spent_mapping = 0 stats_dto.time_spent_validating = 0 - - query = ( - TaskHistory.query.with_entities( - func.date_trunc("minute", TaskHistory.action_date).label("trn"), - func.max(TaskHistory.action_text).label("tm"), + # Total validation time + total_validation_time_query = """ + WITH max_action_text_per_minute AS ( + SELECT + date_trunc('minute', action_date) AS trn, + MAX(action_text) AS tm + FROM task_history + WHERE user_id = :user_id + AND action = 'LOCKED_FOR_VALIDATION' + GROUP BY date_trunc('minute', action_date) ) - .filter(TaskHistory.user_id == user.id) - .filter(TaskHistory.action == "LOCKED_FOR_VALIDATION") - .group_by("trn") - .subquery() + SELECT + SUM(EXTRACT(EPOCH FROM to_timestamp(tm, 'HH24:MI:SS') - to_timestamp('00:00:00', 'HH24:MI:SS'))) + AS total_time + FROM max_action_text_per_minute + """ + result = await db.fetch_one( + total_validation_time_query, values={"user_id": user.id} ) - total_validation_time = db.session.query( - func.sum(cast(func.to_timestamp(query.c.tm, "HH24:MI:SS"), Time)) - ).scalar() - - if total_validation_time: - stats_dto.time_spent_validating = total_validation_time.total_seconds() + if result and result["total_time"]: + stats_dto.time_spent_validating = int(result["total_time"]) stats_dto.total_time_spent += stats_dto.time_spent_validating - total_mapping_time = ( - db.session.query( - func.sum( - cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) + # Total mapping time + total_mapping_time_query = """ + SELECT SUM( + EXTRACT( + EPOCH FROM to_timestamp(action_text, 'HH24:MI:SS') - + to_timestamp('00:00:00', 'HH24:MI:SS') ) - ) - .filter( - or_( - TaskHistory.action == TaskAction.LOCKED_FOR_MAPPING.name, - TaskHistory.action == TaskAction.AUTO_UNLOCKED_FOR_MAPPING.name, - ) - ) - .filter(TaskHistory.user_id == user.id) - .scalar() + ) AS total_mapping_time_seconds + FROM task_history + WHERE user_id = :user_id + AND action IN ('LOCKED_FOR_MAPPING', 'AUTO_UNLOCKED_FOR_MAPPING') + """ + result = await db.fetch_one( + total_mapping_time_query, values={"user_id": user.id} ) - - if total_mapping_time: - stats_dto.time_spent_mapping = total_mapping_time.total_seconds() + if result and result["total_mapping_time_seconds"]: + stats_dto.time_spent_mapping = int(result["total_mapping_time_seconds"]) stats_dto.total_time_spent += stats_dto.time_spent_mapping - stats_dto.contributions_interest = UserService.get_interests_stats(user.id) - + stats_dto.contributions_interest = await UserService.get_interests_stats( + user["id"], db + ) return stats_dto @staticmethod - def update_user_details(user_id: int, user_dto: UserDTO) -> dict: + async def update_user_details( + user_id: int, user_dto: UserDTO, db: Database + ) -> dict: """Update user with info supplied by user, if they add or change their email address a verification mail will be sent""" - user = UserService.get_user_by_id(user_id) - + user = await UserService.get_user_by_id(user_id, db) verification_email_sent = False if ( user_dto.email_address and user.email_address != user_dto.email_address.lower() ): # Send user verification email if they are adding or changing their email address - SMTPService.send_verification_email( + await SMTPService.send_verification_email( user_dto.email_address.lower(), user.username ) - user.set_email_verified_status(is_verified=False) + await User.set_email_verified_status(user, is_verified=False, db=db) verification_email_sent = True - user.update(user_dto) - user_email = UserEmail.query.filter( - UserEmail.email == user_dto.email_address - ).one_or_none() + await User.update(user, user_dto, db) + query = select(UserEmail).filter(UserEmail.email == user_dto.email_address) + user_email = await db.fetch_one(query=query) if user_email is not None: - user_email.delete() + await UserEmail.delete(user, db) return dict(verificationEmailSent=verification_email_sent) @staticmethod - def get_all_users(query: UserSearchQuery) -> UserSearchDTO: + async def get_all_users(query: UserSearchQuery, db: Database) -> UserSearchDTO: """Gets paginated list of users""" - return User.get_all_users(query) + return await User.get_all_users(query, db) @staticmethod - @cached(user_filter_cache) - def filter_users(username: str, project_id: int, page: int) -> UserFilterDTO: + # @cached(user_filter_cache) + async def filter_users( + username: str, project_id: int, page: int, db: Database + ) -> UserFilterDTO: """Gets paginated list of users, filtered by username, for autocomplete""" - return User.filter_users(username, project_id, page) + return await User.filter_users(username, project_id, page, db) @staticmethod - def is_user_an_admin(user_id: int) -> bool: + async def is_user_an_admin(user_id: int, db: Database) -> bool: """Is the user an admin""" - user = UserService.get_user_by_id(user_id) + user = await UserService.get_user_by_id(user_id, db) if UserRole(user.role) == UserRole.ADMIN: return True @@ -518,10 +574,9 @@ def is_user_the_project_author(user_id: int, author_id: int) -> bool: return user_id == author_id @staticmethod - def get_mapping_level(user_id: int): + async def get_mapping_level(user_id: int, db: Database): """Gets mapping level user is at""" - user = UserService.get_user_by_id(user_id) - + user = await UserService.get_user_by_id(user_id, db) return MappingLevel(user.mapping_level) @staticmethod @@ -537,141 +592,185 @@ def is_user_validator(user_id: int) -> bool: return False @staticmethod - def is_user_blocked(user_id: int) -> bool: + async def is_user_blocked(user_id: int, db: Database) -> bool: """Determines if a user is blocked""" - user = UserService.get_user_by_id(user_id) - + user = await UserService.get_user_by_id(user_id, db) if UserRole(user.role) == UserRole.READ_ONLY: return True return False @staticmethod - def get_countries_contributed(user_id: int): - query = ( - TaskHistory.query.with_entities( - func.unnest(Project.country).label("country"), - TaskHistory.action_text, - func.count(TaskHistory.action_text).label("count"), - ) - .filter(TaskHistory.user_id == user_id) - .filter( - TaskHistory.action_text.in_( - [ - TaskStatus.MAPPED.name, - TaskStatus.BADIMAGERY.name, - TaskStatus.VALIDATED.name, - ] - ) - ) - .group_by("country", TaskHistory.action_text) - .outerjoin(Project, Project.id == TaskHistory.project_id) - .all() - ) - countries = list(set([q.country for q in query])) - result = [] - for country in countries: - values = [q for q in query if q.country == country] - - # Filter element to sum mapped values. - mapped = sum( - [ - v.count - for v in values - if v.action_text - in [TaskStatus.MAPPED.name, TaskStatus.BADIMAGERY.name] - ] - ) - validated = sum( - [v.count for v in values if v.action_text == TaskStatus.VALIDATED.name] - ) - dto = UserCountryContributed( - dict( - name=country, - mapped=mapped, - validated=validated, - total=mapped + validated, - ) + async def get_countries_contributed(user_id: int, db: Database): + query = """ + WITH country_stats AS ( + SELECT + unnest(projects.country) AS country, + task_history.action_text, + COUNT(task_history.action_text) AS count + FROM task_history + LEFT JOIN projects ON task_history.project_id = projects.id + WHERE task_history.user_id = :user_id + AND task_history.action_text IN ('MAPPED', 'BADIMAGERY', 'VALIDATED') + GROUP BY country, task_history.action_text + ), + aggregated_stats AS ( + SELECT + country, + SUM(CASE + WHEN action_text IN ('MAPPED', 'BADIMAGERY') THEN count + ELSE 0 + END) AS mapped, + SUM(CASE + WHEN action_text = 'VALIDATED' THEN count + ELSE 0 + END) AS validated + FROM country_stats + GROUP BY country ) - result.append(dto) + SELECT + country AS name, + COALESCE(mapped, 0) AS mapped, + COALESCE(validated, 0) AS validated, + COALESCE(mapped, 0) + COALESCE(validated, 0) AS total + FROM aggregated_stats + WHERE country IS NOT NULL + ORDER BY total DESC; + """ - # Order by total - result = sorted(result, reverse=True, key=lambda i: i.total) - countries_dto = UserCountriesContributed() - countries_dto.countries_contributed = result - countries_dto.total = len(result) + results = await db.fetch_all(query=query, values={"user_id": user_id}) + countries_contributed = [UserCountryContributed(**record) for record in results] - return countries_dto + return UserCountriesContributed( + countries_contributed=countries_contributed, + total=len(countries_contributed), + ) @staticmethod - def upsert_mapped_projects(user_id: int, project_id: int, local_session=None): + async def upsert_mapped_projects(user_id: int, project_id: int, db: Database): """Add project to mapped projects if it doesn't exist, otherwise return""" - User.upsert_mapped_projects(user_id, project_id, local_session=local_session) + await User.upsert_mapped_projects(user_id, project_id, db) @staticmethod - def get_mapped_projects(user_name: str, preferred_locale: str): + async def get_mapped_projects(user_name: str, preferred_locale: str, db: Database): """Gets all projects a user has mapped or validated on""" - user = UserService.get_user_by_username(user_name) - return User.get_mapped_projects(user.id, preferred_locale) + user = await UserService.get_user_by_username(user_name, db) + return await User.get_mapped_projects(user.id, preferred_locale, db) @staticmethod - def get_recommended_projects(user_name: str, preferred_locale: str): - """Gets all projects a user has mapped or validated on""" + async def get_recommended_projects( + user_name: str, preferred_locale: str, db: Database + ) -> ProjectSearchResultsDTO: from backend.services.project_search_service import ProjectSearchService + """Gets all projects a user has mapped or validated on""" limit = 20 - user = ( - User.query.with_entities(User.id, User.mapping_level) - .filter(User.username == user_name) - .one_or_none() - ) - if user is None: + + # Get user details + user_query = """ + SELECT id, mapping_level + FROM users + WHERE username = :user_name + """ + user = await db.fetch_one(user_query, {"user_name": user_name}) + if not user: raise NotFound(sub_code="USER_NOT_FOUND", username=user_name) - # Get all projects that the user has contributed - sq = ( - TaskHistory.query.with_entities(TaskHistory.project_id.label("project_id")) - .distinct(TaskHistory.project_id) - .filter(TaskHistory.user_id == user.id) - .subquery() - ) - # Get all campaigns for all contributed projects. - campaign_tags = ( - Project.query.with_entities(Project.campaign.label("tag")) - .filter(or_(Project.author_id == user.id, Project.id == sq.c.project_id)) - .subquery() + # Get all projects the user has contributed to + contributed_projects_query = """ + SELECT DISTINCT project_id + FROM task_history + WHERE user_id = :user_id + """ + contributed_projects = await db.fetch_all( + contributed_projects_query, {"user_id": user["id"]} ) - # Get projects with given campaign tags but without user contributions. - query = ProjectSearchService.create_search_query() - projs = ( - query.filter(Project.campaign.any(campaign_tags.c.tag)).limit(limit).all() + contributed_project_ids = [row["project_id"] for row in contributed_projects] + + # Fetch campaign tags for contributed or authored projects + campaign_tags_query = """ + SELECT DISTINCT c.name AS tag + FROM campaigns c + JOIN campaign_projects cp ON c.id = cp.campaign_id + WHERE cp.project_id = ANY(:project_ids) OR :user_id IN ( + SELECT p.author_id + FROM projects p + WHERE p.id = cp.project_id + ) + """ + campaign_tags = await db.fetch_all( + query=campaign_tags_query, + values={"user_id": user["id"], "project_ids": contributed_project_ids}, ) - # Get only user mapping level projects. - len_projs = len(projs) + campaign_tags_set = {row["tag"] for row in campaign_tags} + # Get projects with matching campaign tags but exclude user contributions + recommended_projects_query = """ + SELECT DISTINCT + p.*, + o.name AS organisation_name, + o.logo AS organisation_logo, + u.name AS author_name, + u.username AS author_username + FROM projects p + LEFT JOIN organisations o ON p.organisation_id = o.id + LEFT JOIN users u ON u.id = p.author_id + JOIN campaign_projects cp ON p.id = cp.project_id + JOIN campaigns c ON cp.campaign_id = c.id + WHERE c.name = ANY(:campaign_tags) + AND p.author_id != :user_id + LIMIT :limit + """ + recommended_projects = await db.fetch_all( + query=recommended_projects_query, + values={ + "campaign_tags": list(campaign_tags_set), + "user_id": user["id"], + "limit": limit, + }, + ) + # Get only projects matching the user's mapping level if needed + len_projs = len(recommended_projects) if len_projs < limit: - remaining_projs = ( - query.filter(Project.difficulty == user.mapping_level) - .limit(limit - len_projs) - .all() + remaining_projects_query = """ + SELECT DISTINCT p.*, o.name AS organisation_name, o.logo AS organisation_logo, + u.name AS author_name, u.username AS author_username + FROM projects p + LEFT JOIN organisations o ON p.organisation_id = o.id + LEFT JOIN users u ON u.id = p.author_id + WHERE difficulty = :mapping_level + LIMIT :remaining_limit + """ + remaining_projects = await db.fetch_all( + remaining_projects_query, + { + "mapping_level": user["mapping_level"], + "remaining_limit": limit - len_projs, + }, ) - projs.extend(remaining_projs) + recommended_projects.extend(remaining_projects) dto = ProjectSearchResultsDTO() - # Get all total contributions for each paginated project. - contrib_counts = ProjectSearchService.get_total_contributions(projs) - - zip_items = zip(projs, contrib_counts) + project_ids = [project["id"] for project in recommended_projects] + contrib_counts = await ProjectSearchService.get_total_contributions( + project_ids, db + ) dto.results = [ - ProjectSearchService.create_result_dto(p, "en", t) for p, t in zip_items + await ProjectSearchService.create_result_dto( + project, preferred_locale, contrib_count, db + ) + for project, contrib_count in zip(recommended_projects, contrib_counts) ] + dto.pagination = None return dto @staticmethod - def add_role_to_user(admin_user_id: int, username: str, role: str): + async def add_role_to_user( + admin_user_id: int, username: str, role: str, db: Database + ): """ Add role to user :param admin_user_id: ID of admin attempting to add the role @@ -687,7 +786,7 @@ def add_role_to_user(admin_user_id: int, username: str, role: str): + f"Unknown role {role} accepted values are ADMIN, PROJECT_MANAGER, VALIDATOR" ) - admin = UserService.get_user_by_id(admin_user_id) + admin = await UserService.get_user_by_id(admin_user_id, db) admin_role = UserRole(admin.role) if admin_role != UserRole.ADMIN and requested_role == UserRole.ADMIN: @@ -695,11 +794,11 @@ def add_role_to_user(admin_user_id: int, username: str, role: str): "NeedAdminRole- You must be an Admin to assign Admin role" ) - user = UserService.get_user_by_username(username) - user.set_user_role(requested_role) + user = await UserService.get_user_by_username(username, db) + await User.set_user_role(user, requested_role, db) @staticmethod - def set_user_mapping_level(username: str, level: str) -> User: + async def set_user_mapping_level(username: str, level: str, db: Database) -> User: """ Sets the users mapping level :raises: UserServiceError @@ -712,106 +811,150 @@ def set_user_mapping_level(username: str, level: str) -> User: + f"Unknown role {level} accepted values are BEGINNER, INTERMEDIATE, ADVANCED" ) - user = UserService.get_user_by_username(username) - user.set_mapping_level(requested_level) + user = await UserService.get_user_by_username(username, db) + await User.set_mapping_level(user, requested_level, db) return user @staticmethod - def set_user_is_expert(user_id: int, is_expert: bool) -> User: + async def set_user_is_expert(user_id: int, is_expert: bool, db: Database) -> User: """ Enabled or disables expert mode for the user :raises: UserServiceError """ - user = UserService.get_user_by_id(user_id) - user.set_is_expert(is_expert) + user = await UserService.get_user_by_id(user_id, db) + await User.set_is_expert(user, is_expert, db) return user @staticmethod - def accept_license_terms(user_id: int, license_id: int): + async def accept_license_terms(user_id: int, license_id: int, db: Database): """Saves the fact user has accepted license terms""" - user = UserService.get_user_by_id(user_id) - user.accept_license_terms(license_id) - - @staticmethod - def has_user_accepted_license(user_id: int, license_id: int): - """Checks if user has accepted specified license""" - user = UserService.get_user_by_id(user_id) - return user.has_user_accepted_licence(license_id) + user = await UserService.get_user_by_id(user_id, db) + await user.accept_license_terms(user_id, license_id, db) + + @staticmethod + async def has_user_accepted_license( + user_id: int, license_id: int, db: Database + ) -> bool: + """Checks if a user has accepted the specified license.""" + query = """ + SELECT EXISTS ( + SELECT 1 + FROM user_licenses + WHERE "user" = :user_id AND license = :license_id + ) + """ + result = await db.fetch_one( + query, values={"user_id": user_id, "license_id": license_id} + ) + return result[0] if result else False @staticmethod - def get_osm_details_for_user(username: str) -> UserOSMDTO: + async def get_osm_details_for_user(username: str, db: Database) -> UserOSMDTO: """ Gets OSM details for the user from OSM API :param username: username in scope :raises UserServiceError, NotFound """ - user = UserService.get_user_by_username(username) + user = await UserService.get_user_by_username(username, db) osm_dto = OSMService.get_osm_details_for_user(user.id) return osm_dto @staticmethod - def check_and_update_mapper_level(user_id: int): - """Check users mapping level and update if they have crossed threshold""" - user = UserService.get_user_by_id(user_id) + async def check_and_update_mapper_level(user_id: int, db: Database): + """Check user's mapping level and update if they have crossed threshold""" + user = await UserService.get_user_by_id(user_id, db) user_level = MappingLevel(user.mapping_level) if user_level == MappingLevel.ADVANCED: - return # User has achieved highest level, so no need to do further checking + return # User has achieved the highest level, no need to proceed - intermediate_level = current_app.config["MAPPER_LEVEL_INTERMEDIATE"] - advanced_level = current_app.config["MAPPER_LEVEL_ADVANCED"] + intermediate_level = settings.MAPPER_LEVEL_INTERMEDIATE + advanced_level = settings.MAPPER_LEVEL_ADVANCED try: osm_details = OSMService.get_osm_details_for_user(user_id) + if ( osm_details.changeset_count > advanced_level and user.mapping_level != MappingLevel.ADVANCED.value ): - user.mapping_level = MappingLevel.ADVANCED.value - UserService.notify_level_upgrade(user_id, user.username, "ADVANCED") + update_query = """ + UPDATE users + SET mapping_level = :new_level + WHERE id = :user_id + """ + await db.execute( + update_query, + {"new_level": MappingLevel.ADVANCED.value, "user_id": user_id}, + ) + await UserService.notify_level_upgrade( + user_id, user.username, "ADVANCED", db + ) + elif ( intermediate_level < osm_details.changeset_count < advanced_level and user.mapping_level != MappingLevel.INTERMEDIATE.value ): - user.mapping_level = MappingLevel.INTERMEDIATE.value - UserService.notify_level_upgrade(user_id, user.username, "INTERMEDIATE") - except OSMServiceError: - # Swallow exception as we don't want to blow up the server for this - current_app.logger.error("Error attempting to update mapper level") - return + update_query = """ + UPDATE users + SET mapping_level = :new_level + WHERE id = :user_id + """ + await db.execute( + update_query, + {"new_level": MappingLevel.INTERMEDIATE.value, "user_id": user_id}, + ) + await UserService.notify_level_upgrade( + user_id, user.username, "INTERMEDIATE", db + ) - user.save() + except OSMServiceError: + # Log the error and move on; don't block the process + logger.error("Error attempting to update mapper level for user %s", user_id) @staticmethod - def notify_level_upgrade(user_id: int, username: str, level: str): + async def notify_level_upgrade( + user_id: int, username: str, level: str, db: Database + ): text_template = get_txt_template("level_upgrade_message_en.txt") + replace_list = [ ["[USERNAME]", username], ["[LEVEL]", level.capitalize()], - ["[ORG_CODE]", current_app.config["ORG_CODE"]], + ["[ORG_CODE]", settings.ORG_CODE], ] text_template = template_var_replacing(text_template, replace_list) - level_upgrade_message = Message() - level_upgrade_message.to_user_id = user_id - level_upgrade_message.subject = ( - f"Congratulations🎉, You're now an {level} mapper." + subject = f"Congratulations🎉, You're now an {level} mapper." + message_type = MessageType.SYSTEM.value + + insert_query = """ + INSERT INTO messages (to_user_id, subject, message, message_type, date, read) + VALUES (:to_user_id, :subject, :message, :message_type, :date, :read) + """ + await db.execute( + insert_query, + { + "to_user_id": user_id, + "subject": subject, + "message": text_template, + "message_type": message_type, + "date": timestamp(), + "read": False, + }, ) - level_upgrade_message.message = text_template - level_upgrade_message.message_type = MessageType.SYSTEM.value - level_upgrade_message.save() @staticmethod - def refresh_mapper_level() -> int: + async def refresh_mapper_level(db: Database) -> int: """Helper function to run thru all users in the DB and update their mapper level""" - users = User.get_all_users_not_paginated() + users = await User.get_all_users_not_paginated(db) users_updated = 1 total_users = len(users) for user in users: - UserService.check_and_update_mapper_level(user.id) + await UserService.check_and_update_mapper_level(user.id, db) if users_updated % 50 == 0: print(f"{users_updated} users updated of {total_users}") @@ -821,30 +964,34 @@ def refresh_mapper_level() -> int: return users_updated @staticmethod - def register_user_with_email(user_dto: UserRegisterEmailDTO): + async def register_user_with_email(user_dto: UserRegisterEmailDTO, db: Database): # Validate that user is not within the general users table. user_email = user_dto.email.lower() - user = User.query.filter(func.lower(User.email_address) == user_email).first() + query = select(User).filter(func.lower(User.email_address) == user_email) + user = await db.fetch_one(query) if user is not None: details_msg = f"Email address {user_email} already exists" raise ValueError(details_msg) - user = UserEmail.query.filter( - func.lower(UserEmail.email) == user_email - ).one_or_none() + query = select(UserEmail).filter(func.lower(UserEmail.email) == user_email) + user = await db.fetch_one(query) if user is None: user = UserEmail(email=user_email) - user.create() - - return user + user = await user.create(db) + return user + else: + return user.id @staticmethod - def get_interests(user: User) -> InterestsListDTO: - dto = InterestsListDTO() - for interest in Interest.query.all(): - int_dto = interest.as_dto() - if interest in user.interests: + async def get_interests(user: User, db: Database) -> InterestsListDTO: + query = """ + SELECT * FROM interests + """ + interests = await db.fetch_all(query) + interest_list_dto = InterestsListDTO() + for interest in interests: + int_dto = InterestDTO(**interest) + if interest.name in user.interests: int_dto.user_selected = True - dto.interests.append(int_dto) - - return dto + interest_list_dto.interests.append(int_dto) + return interest_list_dto diff --git a/backend/services/validator_service.py b/backend/services/validator_service.py index 653110b840..6040c4dacf 100644 --- a/backend/services/validator_service.py +++ b/backend/services/validator_service.py @@ -1,50 +1,54 @@ -from flask import current_app +import asyncio +import datetime +from typing import Set + +from databases import Database +from fastapi import BackgroundTasks +from loguru import logger from sqlalchemy import text -from multiprocessing.dummy import Pool as ThreadPool -from sqlalchemy.orm import scoped_session, sessionmaker -import os -from backend import db +from backend.db import db_connection from backend.exceptions import NotFound from backend.models.dtos.mapping_dto import TaskDTOs from backend.models.dtos.stats_dto import Pagination from backend.models.dtos.validator_dto import ( - LockForValidationDTO, - UnlockAfterValidationDTO, - MappedTasks, - StopValidationDTO, InvalidatedTask, InvalidatedTasks, + LockForValidationDTO, + MappedTasks, RevertUserTasksDTO, + StopValidationDTO, + UnlockAfterValidationDTO, ) +from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.statuses import ValidatingNotAllowed from backend.models.postgis.task import ( Task, - TaskStatus, TaskHistory, TaskInvalidationHistory, TaskMappingIssue, + TaskStatus, ) -from backend.models.postgis.utils import UserLicenseError, timestamp -from backend.models.postgis.project_info import ProjectInfo +from backend.models.postgis.utils import UserLicenseError +from backend.services.mapping_service import MappingService from backend.services.messaging.message_service import MessageService -from backend.services.project_service import ProjectService, ProjectAdminService +from backend.services.project_service import ProjectAdminService, ProjectService from backend.services.stats_service import StatsService from backend.services.users.user_service import UserService -from backend.services.mapping_service import MappingService class ValidatorServiceError(Exception): """Custom exception to notify callers that error has occurred""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ValidatorService: @staticmethod - def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: + async def lock_tasks_for_validation( + validation_dto: LockForValidationDTO, db: Database + ) -> TaskDTOs: """ Lock supplied tasks for validation :raises ValidatorServiceError @@ -52,7 +56,7 @@ def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: # Loop supplied tasks to check they can all be locked for validation tasks_to_lock = [] for task_id in validation_dto.task_ids: - task = Task.get(task_id, validation_dto.project_id) + task = await Task.get(task_id, validation_dto.project_id, db) if task is None: raise NotFound( @@ -72,8 +76,8 @@ def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: raise ValidatorServiceError( f"NotReadyForValidation- Task {task_id} is not MAPPED, BADIMAGERY or INVALIDATED" ) - user_can_validate = ValidatorService._user_can_validate_task( - validation_dto.user_id, task.mapped_by + user_can_validate = await ValidatorService._user_can_validate_task( + validation_dto.user_id, task.mapped_by, db ) if not user_can_validate: raise ValidatorServiceError( @@ -83,8 +87,11 @@ def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: tasks_to_lock.append(task) - user_can_validate, error_reason = ProjectService.is_user_permitted_to_validate( - validation_dto.project_id, validation_dto.user_id + ( + user_can_validate, + error_reason, + ) = await ProjectService.is_user_permitted_to_validate( + validation_dto.project_id, validation_dto.user_id, db ) if not user_can_validate: @@ -99,7 +106,9 @@ def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: "ProjectNotPublished- Validation not allowed because: Project not published" ) elif error_reason == ValidatingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED: - user_tasks = Task.get_locked_tasks_for_user(validation_dto.user_id) + user_tasks = await Task.get_locked_tasks_for_user( + validation_dto.user_id, db + ) if set(user_tasks.locked_tasks) != set(validation_dto.task_ids): raise ValidatorServiceError( "UserAlreadyHasTaskLocked- User already has a task locked" @@ -112,16 +121,26 @@ def lock_tasks_for_validation(validation_dto: LockForValidationDTO) -> TaskDTOs: # Lock all tasks for validation dtos = [] for task in tasks_to_lock: - task.lock_task_for_validating(validation_dto.user_id) - dtos.append(task.as_dto_with_instructions(validation_dto.preferred_locale)) - + await Task.lock_task_for_validating( + task.id, validation_dto.project_id, validation_dto.user_id, db + ) + dtos.append( + await Task.as_dto_with_instructions( + task.id, + validation_dto.project_id, + db, + validation_dto.preferred_locale, + ) + ) task_dtos = TaskDTOs() task_dtos.tasks = dtos return task_dtos @staticmethod - def _user_can_validate_task(user_id: int, mapped_by: int) -> bool: + async def _user_can_validate_task( + user_id: int, mapped_by: int, db: Database + ) -> bool: """ check whether a user is able to validate a task. Users cannot validate their own tasks unless they are a PM (admin counts as project manager too) @@ -129,7 +148,7 @@ def _user_can_validate_task(user_id: int, mapped_by: int) -> bool: :param mapped_by: id of user who mapped the task :return: Boolean """ - is_admin = UserService.is_user_an_admin(user_id) + is_admin = await UserService.is_user_an_admin(user_id, db) if is_admin: return True else: @@ -138,81 +157,109 @@ def _user_can_validate_task(user_id: int, mapped_by: int) -> bool: return True return False - @staticmethod - def _process_tasks(args): - ( - app_context, - task_to_unlock, - project_id, - validated_dto, - message_sent_to, - dtos, - ) = args - with app_context: - Session = scoped_session(sessionmaker(bind=db.engine)) - local_session = Session() + async def process_task( + project_id, + task_to_unlock, + validated_dto, + message_sent_to: Set[int], + message_lock: asyncio.Lock, + ): + async with db_connection.database.connection() as db: task = task_to_unlock["task"] - task = ( - local_session.query(Task) - .filter_by(id=task.id, project_id=project_id) - .one() - ) - if task_to_unlock["comment"]: - # Parses comment to see if any users have been @'d - MessageService.send_message_after_comment( + await MessageService.send_message_after_comment( validated_dto.user_id, task_to_unlock["comment"], task.id, validated_dto.project_id, + db, ) if ( task_to_unlock["new_state"] == TaskStatus.VALIDATED or task_to_unlock["new_state"] == TaskStatus.INVALIDATED ): - # All mappers get a notification if their task has been validated or invalidated. - # Only once if multiple tasks mapped - if task.mapped_by not in message_sent_to: - MessageService.send_message_after_validation( + mapped_by = task.mapped_by + # acquire lock so two coros can’t both think “not yet sent” + async with message_lock: + need_to_send = mapped_by not in message_sent_to + if need_to_send: + message_sent_to.add(mapped_by) + + if need_to_send: + await MessageService.send_message_after_validation( task_to_unlock["new_state"], validated_dto.user_id, - task.mapped_by, + mapped_by, task.id, - validated_dto.project_id, + project_id, + db, ) - message_sent_to.append(task.mapped_by) + # Set last_validation_date for the mapper to current date if task_to_unlock["new_state"] == TaskStatus.VALIDATED: - # Set last_validation_date for the mapper to current date - task.mapper.last_validation_date = timestamp() + query = """ + UPDATE users + SET last_validation_date = :timestamp + WHERE id = ( + SELECT mapped_by + FROM tasks + WHERE id = :task_id + AND project_id = :project_id + ); + """ + values = { + "timestamp": datetime.datetime.utcnow(), + "task_id": task.id, + "project_id": validated_dto.project_id, + } + await db.execute(query=query, values=values) # Update stats if user setting task to a different state from previous state - prev_status = TaskHistory.get_last_status(project_id, task.id) + prev_status = await TaskHistory.get_last_status(project_id, task.id, db) if prev_status != task_to_unlock["new_state"]: - StatsService.update_stats_after_task_state_change( + await StatsService.update_stats_after_task_state_change( validated_dto.project_id, validated_dto.user_id, prev_status, task_to_unlock["new_state"], - local_session=local_session, + db, ) - task_mapping_issues = ValidatorService.get_task_mapping_issues( + task_mapping_issues = await ValidatorService.get_task_mapping_issues( task_to_unlock ) - task.unlock_task( - validated_dto.user_id, - task_to_unlock["new_state"], - task_to_unlock["comment"], + await Task.unlock_task( + task_id=task.id, + project_id=project_id, + user_id=validated_dto.user_id, + new_state=task_to_unlock["new_state"], + db=db, + comment=task_to_unlock["comment"], issues=task_mapping_issues, - local_session=local_session, ) - dtos.append(task.as_dto_with_instructions(validated_dto.preferred_locale)) - local_session.commit() - Session.remove() + + return await Task.as_dto_with_instructions( + task.id, project_id, db, validated_dto.preferred_locale + ) + + async def process_tasks_concurrently(project_id, tasks_to_unlock, validated_dto): + """ + Process tasks concurrently and ensure each task gets its own DB connection. + """ + message_sent_to: set[int] = set() + message_lock = asyncio.Lock() + tasks = [ + ValidatorService.process_task( + project_id, task_to_unlock, validated_dto, message_sent_to, message_lock + ) + for task_to_unlock in tasks_to_unlock + ] + return await asyncio.gather(*tasks) @staticmethod - def unlock_tasks_after_validation( + async def unlock_tasks_after_validation( validated_dto: UnlockAfterValidationDTO, + db: Database, + background_tasks: BackgroundTasks, ) -> TaskDTOs: """ Unlocks supplied tasks after validation @@ -221,42 +268,24 @@ def unlock_tasks_after_validation( validated_tasks = validated_dto.validated_tasks project_id = validated_dto.project_id user_id = validated_dto.user_id - tasks_to_unlock = ValidatorService.get_tasks_locked_by_user( - project_id, validated_tasks, user_id + tasks_to_unlock = await ValidatorService.get_tasks_locked_by_user( + project_id, validated_tasks, user_id, db + ) + results = await ValidatorService.process_tasks_concurrently( + project_id, tasks_to_unlock, validated_dto + ) + background_tasks.add_task( + ProjectService.send_email_on_project_progress, + validated_dto.project_id, ) - - # Unlock all tasks - dtos = [] - message_sent_to = [] - args_list = [] - for task_to_unlock in tasks_to_unlock: - args = ( - current_app.app_context(), - task_to_unlock, - project_id, - validated_dto, - message_sent_to, - dtos, - ) - args_list.append(args) - - # Create a pool and Process the tasks in parallel - pool = ThreadPool(os.cpu_count()) - pool.map(ValidatorService._process_tasks, args_list) - - # Close the pool and wait for the threads to finish - pool.close() - pool.join() - - # Send email on project progress - ProjectService.send_email_on_project_progress(validated_dto.project_id) task_dtos = TaskDTOs() - task_dtos.tasks = dtos - + task_dtos.tasks = results return task_dtos @staticmethod - def stop_validating_tasks(stop_validating_dto: StopValidationDTO) -> TaskDTOs: + async def stop_validating_tasks( + stop_validating_dto: StopValidationDTO, db: Database + ) -> TaskDTOs: """ Unlocks supplied tasks after validation :raises ValidatorServiceError @@ -264,46 +293,53 @@ def stop_validating_tasks(stop_validating_dto: StopValidationDTO) -> TaskDTOs: reset_tasks = stop_validating_dto.reset_tasks project_id = stop_validating_dto.project_id user_id = stop_validating_dto.user_id - tasks_to_unlock = ValidatorService.get_tasks_locked_by_user( - project_id, reset_tasks, user_id + tasks_to_unlock = await ValidatorService.get_tasks_locked_by_user( + project_id, reset_tasks, user_id, db ) - dtos = [] for task_to_unlock in tasks_to_unlock: task = task_to_unlock["task"] - if task_to_unlock["comment"]: # Parses comment to see if any users have been @'d - MessageService.send_message_after_comment( - user_id, task_to_unlock["comment"], task.id, project_id + await MessageService.send_message_after_comment( + user_id, task_to_unlock["comment"], task.id, project_id, db ) - - task.reset_lock(user_id, task_to_unlock["comment"]) + await Task.reset_lock( + task.id, + project_id, + task.task_status, + user_id, + task_to_unlock["comment"], + db, + ) dtos.append( - task.as_dto_with_instructions(stop_validating_dto.preferred_locale) + await Task.as_dto_with_instructions( + task.id, project_id, db, stop_validating_dto.preferred_locale + ) ) - task_dtos = TaskDTOs() task_dtos.tasks = dtos - return task_dtos @staticmethod - def get_tasks_locked_by_user(project_id: int, unlock_tasks, user_id: int): + async def get_tasks_locked_by_user( + project_id: int, unlock_tasks: list, user_id: int, db: Database + ): """ Returns tasks specified by project id and unlock_tasks list if found and locked for validation by user, - otherwise raises ValidatorServiceError, NotFound - :param project_id: - :param unlock_tasks: List of tasks to be unlocked - :param user_id: - :return: List of Tasks - :raises ValidatorServiceError - :raises NotFound + otherwise raises ValidatorServiceError, NotFound. + + :param project_id: ID of the project. + :param unlock_tasks: List of tasks to be unlocked. + :param user_id: ID of the user attempting to unlock tasks. + :param db: Async database connection. + :return: List of tasks to unlock with new states and comments. + :raises ValidatorServiceError: When task is not locked for validation or owned by another user. + :raises NotFound: When task is not found. """ tasks_to_unlock = [] - # Loop supplied tasks to check they can all be unlocked for unlock_task in unlock_tasks: - task = Task.get(unlock_task.task_id, project_id) + task = await Task.get(unlock_task.task_id, project_id, db) if task is None: raise NotFound( @@ -315,19 +351,18 @@ def get_tasks_locked_by_user(project_id: int, unlock_tasks, user_id: int): current_state = TaskStatus(task.task_status) if current_state != TaskStatus.LOCKED_FOR_VALIDATION: raise ValidatorServiceError( - f"NotLockedForValidation- Task {unlock_task.task_id} is not LOCKED_FOR_VALIDATION" + f"NotLockedForValidation - Task {unlock_task.task_id} is not LOCKED_FOR_VALIDATION" ) - if task.locked_by != user_id: raise ValidatorServiceError( - "TaskNotOwned- Attempting to unlock a task owned by another user" + "TaskNotOwned - Attempting to unlock a task owned by another user" ) - if hasattr(unlock_task, "status"): - # we know what status we ate going to be setting to on unlock - new_status = TaskStatus[unlock_task.status] - else: - new_status = None + new_status = ( + TaskStatus[unlock_task.status] + if hasattr(unlock_task, "status") + else None + ) tasks_to_unlock.append( dict( @@ -341,9 +376,9 @@ def get_tasks_locked_by_user(project_id: int, unlock_tasks, user_id: int): return tasks_to_unlock @staticmethod - def get_mapped_tasks_by_user(project_id: int) -> MappedTasks: + async def get_mapped_tasks_by_user(project_id: int, db: Database) -> MappedTasks: """Get all mapped tasks on the project grouped by user""" - mapped_tasks = Task.get_mapped_tasks_by_user(project_id) + mapped_tasks = await Task.get_mapped_tasks_by_user(project_id, db) return mapped_tasks @staticmethod @@ -397,51 +432,105 @@ def get_user_invalidated_tasks( return invalidated_tasks_dto @staticmethod - def invalidate_all_tasks(project_id: int, user_id: int): - """Invalidates all validated tasks on a project""" - validated_tasks = Task.query.filter( - Task.project_id == project_id, - Task.task_status == TaskStatus.VALIDATED.value, - ).all() + async def invalidate_all_tasks(project_id: int, user_id: int, db: Database): + """Invalidates all validated tasks on a project.""" + query = """ + SELECT id, task_status FROM tasks + WHERE project_id = :project_id + AND task_status = :validated_status + """ + validated_tasks = await db.fetch_all( + query=query, + values={ + "project_id": project_id, + "validated_status": TaskStatus.VALIDATED.value, + }, + ) for task in validated_tasks: - task.lock_task_for_validating(user_id) - task.unlock_task(user_id, new_state=TaskStatus.INVALIDATED) + await Task.lock_task_for_validating(task["id"], project_id, user_id, db) + await Task.unlock_task( + task["id"], project_id, user_id, TaskStatus.INVALIDATED, db + ) - # Reset counters - project = ProjectService.get_project_by_id(project_id) - project.tasks_validated = 0 - project.save() + # Reset counters for the project + project_query = """ + UPDATE projects + SET tasks_validated = 0 + WHERE id = :project_id + """ + await db.execute(query=project_query, values={"project_id": project_id}) @staticmethod - def validate_all_tasks(project_id: int, user_id: int): - """Validates all mapped tasks on a project""" - tasks_to_validate = Task.query.filter( - Task.project_id == project_id, - Task.task_status == TaskStatus.MAPPED.value, - ).all() + async def validate_all_tasks(project_id: int, user_id: int, db: Database): + """Validates all mapped tasks on a project using raw SQL queries""" + + # Fetch tasks that are in the MAPPED state + query = """ + SELECT id, task_status, mapped_by + FROM tasks + WHERE project_id = :project_id + AND task_status = :mapped_status + """ + tasks_to_validate = await db.fetch_all( + query=query, + values={ + "project_id": project_id, + "mapped_status": TaskStatus.MAPPED.value, + }, + ) for task in tasks_to_validate: - task.mapped_by = task.mapped_by or user_id # Ensure we set mapped by value - if TaskStatus(task.task_status) not in [ + task_id = task["id"] + mapped_by = ( + task["mapped_by"] or user_id + ) # Ensure we set the 'mapped_by' value + + # Lock the task for validation if it's not already locked + current_status = TaskStatus(task["task_status"]) + if current_status not in [ TaskStatus.LOCKED_FOR_MAPPING, TaskStatus.LOCKED_FOR_VALIDATION, ]: - # Only lock tasks that are not already locked to avoid double lock issue - task.lock_task_for_validating(user_id) + await Task.lock_task_for_validating(task_id, project_id, user_id, db) + + # Unlock the task and set its status to VALIDATED + await Task.unlock_task( + task_id=task_id, + project_id=project_id, + user_id=user_id, + new_state=TaskStatus.VALIDATED, + db=db, + ) - task.unlock_task(user_id, new_state=TaskStatus.VALIDATED) + # Update the mapped_by field if necessary + update_mapped_by_query = """ + UPDATE tasks + SET mapped_by = :mapped_by + WHERE id = :task_id + AND project_id = :project_id + """ + await db.execute( + query=update_mapped_by_query, + values={ + "mapped_by": mapped_by, + "task_id": task_id, + "project_id": project_id, + }, + ) - # Set counters to fully mapped and validated - project = ProjectService.get_project_by_id(project_id) - project.tasks_validated += project.tasks_mapped - project.tasks_mapped = 0 - project.save() + # Update the project's task counters using raw SQL + project_update_query = """ + UPDATE projects + SET tasks_validated = tasks_validated + tasks_mapped, + tasks_mapped = 0 + WHERE id = :project_id + """ + await db.execute(query=project_update_query, values={"project_id": project_id}) @staticmethod - def get_task_mapping_issues(task_to_unlock: dict): + async def get_task_mapping_issues(task_to_unlock: dict): if task_to_unlock["issues"] is None: return None - # map ValidationMappingIssue DTOs to TaskMappingIssue instances for any issues # that have count above zero. return list( @@ -456,29 +545,37 @@ def get_task_mapping_issues(task_to_unlock: dict): ) @staticmethod - def revert_user_tasks(revert_dto: RevertUserTasksDTO): + async def revert_user_tasks(revert_dto: RevertUserTasksDTO, db: Database): """ - Reverts tasks with supplied action to previous state by specific user + Reverts tasks with the supplied action to the previous state by a specific user. :raises ValidatorServiceError """ - if ProjectAdminService.is_user_action_permitted_on_project( - revert_dto.action_by, revert_dto.project_id + if await ProjectAdminService.is_user_action_permitted_on_project( + revert_dto.action_by, revert_dto.project_id, db ): - query = Task.query.filter( - Task.project_id == revert_dto.project_id, - Task.task_status == TaskStatus[revert_dto.action].value, - ) + query = """ + SELECT id + FROM tasks + WHERE project_id = :project_id + AND task_status = :task_status + """ + values = { + "project_id": revert_dto.project_id, + "task_status": TaskStatus[revert_dto.action].value, + } if TaskStatus[revert_dto.action].value == TaskStatus.BADIMAGERY.value: - query = query.filter(Task.mapped_by == revert_dto.user_id) + query += " AND mapped_by = :user_id" + values["user_id"] = revert_dto.user_id else: - query = query.filter(Task.validated_by == revert_dto.user_id) - - tasks_to_revert = query.all() + query += " AND validated_by = :user_id" + values["user_id"] = revert_dto.user_id + tasks_to_revert = await db.fetch_all(query=query, values=values) for task in tasks_to_revert: - task = MappingService.undo_mapping( + await MappingService.undo_mapping( revert_dto.project_id, - task.id, + task["id"], revert_dto.user_id, + db, revert_dto.preferred_locale, ) else: diff --git a/docker-compose.yml b/docker-compose.yml index e8f0c294f3..ffb13b62ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,8 @@ services: - tasking-manager.env restart: unless-stopped healthcheck: - test: psql -h 0.0.0.0 -U ${POSTGRES_USER:-tm} -d ${POSTGRES_DB:-tasking-manager} -c 'SELECT 1;' - start_period: 5s + test: pg_isready -U ${POSTGRES_USER:-tm} -d ${POSTGRES_DB:-test_db} + start_period: 35s interval: 10s timeout: 5s retries: 3 @@ -29,6 +29,8 @@ services: image: ghcr.io/hotosm/tasking-manager/backend:main build: context: . + target: ${TARGET_TAG:-debug} + dockerfile: ./scripts/docker/Dockerfile depends_on: tm-db: condition: service_healthy @@ -37,15 +39,19 @@ services: env_file: - tasking-manager.env volumes: - - ./pyproject.toml:/usr/src/app/pyproject.toml:ro - - ./backend:/usr/src/app/backend:ro - - ./tests:/usr/src/app/tests:ro - - ./migrations:/src/migrations + - ./pyproject.toml:/usr/src/app/pyproject.toml + - ./uv.lock:/usr/src/app/uv.lock + - ./backend:/usr/src/app/backend + - ./tests:/usr/src/app/tests + - ./migrations:/usr/src/app/migrations/ + ports: + - 5678:5678 #DebugPy restart: unless-stopped healthcheck: - test: curl --fail http://localhost:5000 || exit 1 + test: curl --fail http://localhost:5000/api/docs interval: 10s retries: 5 + start_period: 10s timeout: 3s deploy: replicas: ${API_REPLICAS:-1} @@ -66,18 +72,22 @@ services: image: ghcr.io/hotosm/tasking-manager/backend:main build: context: . - entrypoint: ["python", "manage.py", "db"] - command: "upgrade" + target: ${TARGET_TAG:-debug} + dockerfile: ./scripts/docker/Dockerfile + entrypoint: ["alembic", "-c", "migrations/alembic.ini", "upgrade", "head"] depends_on: tm-db: condition: service_healthy env_file: - tasking-manager.env volumes: - - ./pyproject.toml:/usr/src/app/pyproject.toml:ro - - ./backend:/usr/src/app/backend:ro - - ./migrations:/usr/src/app/migrations:ro + - ./pyproject.toml:/usr/src/app/pyproject.toml + - ./uv.lock:/usr/src/app/uv.lock + - ./backend:/usr/src/app/backend + - ./tests:/usr/src/app/tests + - ./migrations:/usr/src/app/migrations/ deploy: + replicas: ${API_REPLICAS:-1} resources: limits: cpus: "1" @@ -88,15 +98,48 @@ services: networks: - tm-net - swagger: - image: swaggerapi/swagger-ui:v5.11.10 - restart: always + tm-cron-jobs: + image: ghcr.io/hotosm/tasking-manager/backend:main + build: + context: . + target: ${TARGET_TAG:-debug} + dockerfile: ./scripts/docker/Dockerfile + command: ["python", "backend/cron_jobs.py"] + depends_on: + tm-db: + condition: service_healthy + env_file: + - tasking-manager.env environment: - - BASE_URL=/docs - - SWAGGER_JSON_URL=http://127.0.0.1:${TM_DEV_PORT:-3000}/api/v2/system/docs/json/ + - PYTHONPATH=/usr/src/app + deploy: + replicas: 1 + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.2" + memory: 128M + networks: + - tm-net + + tm-frontend: + # NOTE: For Frontend development with docker-compose + # - Uncomment volume section below to enable live reload of frontend code + # - Add `TARGET_TAG=debug` to your `tasking-manager.env` or new `.env` file + image: ghcr.io/hotosm/tasking-manager/frontend:main + build: + context: . + dockerfile: "./scripts/docker/Dockerfile.frontend" + target: ${TARGET_TAG:-prod} + env_file: + - tasking-manager.env labels: - - traefik.http.routers.swagger.rule=(Host(`127.0.0.1`) || Host(`localhost`)) && PathPrefix(`/docs/`) - - traefik.http.services.swagger.loadbalancer.server.port=8080 + - traefik.http.routers.frontend.rule=Host(`127.0.0.1`) || Host(`localhost`) + - traefik.http.services.frontend.loadbalancer.server.port=3000 + # volumes: + # - ./frontend:/usr/src/app networks: - tm-net @@ -112,16 +155,3 @@ services: - --providers.docker=true networks: - tm-net - - tm-frontend: - image: ghcr.io/hotosm/tasking-manager/frontend:main - build: - context: . - dockerfile: "./scripts/docker/Dockerfile.frontend_development" - env_file: - - tasking-manager.env - labels: - - traefik.http.routers.frontend.rule=Host(`127.0.0.1`) || Host(`localhost`) - - traefik.http.services.frontend.loadbalancer.server.port=3000 - networks: - - tm-net diff --git a/docs/developers/contributing-guidelines.md b/docs/developers/contributing-guidelines.md index 97522cbf06..ae4ab1659b 100644 --- a/docs/developers/contributing-guidelines.md +++ b/docs/developers/contributing-guidelines.md @@ -109,14 +109,14 @@ Before sending a PR, make sure you run the following commands and include the changes in your commit. * Code formatting: - * Format all backend code by running [Black](https://pypi.org/project/black/): `black manage.py backend tests migrations` or `pdm run lint` + * Format all backend code by running [Black](https://pypi.org/project/black/): `black manage.py backend tests migrations` or `uv run lint` * Format all frontend code with [prettier](https://prettier.io/) either by [configuring your editor](https://prettier.io/docs/en/editors.html) or by running `yarn prettier` inside the `frontend` directory. * Coding standards: Make sure you adhere to the coding standards eventually risen by [Flake8](http://flake8.pycqa.org/en/latest/): - `flake8 manage.py backend tests migrations` or `pdm run flake8` + `flake8 manage.py backend tests migrations` or `uv run flake8` * Prepare for translations: In case you have introduced new strings on the frontend, the translation source file must be updated this can be done via `make refresh-translatables` or `yarn build-locales` diff --git a/docs/developers/development-setup.md b/docs/developers/development-setup.md index c02bec10ba..2a360598a4 100644 --- a/docs/developers/development-setup.md +++ b/docs/developers/development-setup.md @@ -272,8 +272,8 @@ In order to send email correctly, set these variables as well: * Install project dependencies: * First ensure the Python version in `pyproject.toml:requires-python` is installed on your system. - * ```pip install --upgrade pdm``` - * ```pdm install``` + * ```pip install --upgrade uv``` + * ```uv sync``` #### Tests @@ -285,7 +285,7 @@ python3 -m unittest discover tests/backend ``` or ``` -pdm run test +uv run test ``` #### Export translatable strings to en.json source file @@ -329,7 +329,7 @@ flask db upgrade ``` or ``` -pdm run upgrade +uv run upgrade ``` #### Set permissions to create projects @@ -348,13 +348,13 @@ architecture. Install the backend dependencies, and run the server: ```bash # Install dependencies -pdm install +uv sync # Run (Option 1) -pdm run start +uv run start # Run (Option 2) -pdm run flask run --debug --reload +uv run flask run --debug --reload ``` You can access the API documentation on diff --git a/docs/sysadmins/migration.md b/docs/sysadmins/migration.md index d1096255a8..7dc52abf60 100644 --- a/docs/sysadmins/migration.md +++ b/docs/sysadmins/migration.md @@ -17,7 +17,7 @@ $ `psql -d myDataBase -a -f scripts/database/duplicate-priority-area-cleanup.sql Now, migrating from version 3 to version 4 can be done through the build in alembic migrations by simply running: -$ `flask db upgrade` or `pdm run upgrade` +$ `flask db upgrade` or `uv run upgrade` Depending on the size of your database this might take a good while. diff --git a/example.env b/example.env index f6e979ce5e..09d55815ae 100644 --- a/example.env +++ b/example.env @@ -9,45 +9,45 @@ # Note: 127.0.0.1 is a hard requirement for OSM Auth (instead of `localhost`) # On production instances, use the public URL of your frontend # TM_APP_BASE_URL=https://tasks.hotosm.org -TM_APP_BASE_URL=http://127.0.0.1:3000 +TM_APP_BASE_URL=${TM_APP_BASE_URL:-http://127.0.0.1:3000} # The TM_APP_API_URL defines the URL of your backend server. It will be used by # both the backend and by the frontend # On development instances it should be 127.0.0.1:3000 # On production instances, use the public URL of your backend -TM_APP_API_URL=http://127.0.0.1:3000/api +TM_APP_API_URL=${TM_APP_API_URL:-http://127.0.0.1:3000/api} # Defines the version of the API and will be used after /api/ on the url -TM_APP_API_VERSION=v2 +TM_APP_API_VERSION=${TM_APP_API_VERSION:-v2} # Information about the hosting organization -TM_ORG_NAME="Humanitarian OpenStreetMap Team" -TM_ORG_CODE=HOT -TM_ORG_LOGO=https://cdn.img.url/logo.png -TM_ORG_URL=https://example.com -TM_ORG_PRIVACY_POLICY_URL=https://www.hotosm.org/privacy -TM_ORG_TWITTER=http://twitter.com/hotosm -TM_ORG_FB=https://www.facebook.com/hotosm -TM_ORG_INSTAGRAM=https://www.instagram.com/open.mapping.hubs/ -TM_ORG_YOUTUBE=https://www.youtube.com/user/hotosm -TM_ORG_GITHUB=https://github.com/hotosm +TM_ORG_NAME=${TM_ORG_NAME:-Humanitarian OpenStreetMap Team} +TM_ORG_CODE=${TM_ORG_CODE:-HOT} +TM_ORG_LOGO=${TM_ORG_LOGO:-https://cdn.img.url/logo.png} +TM_ORG_URL=${TM_ORG_URL:-https://example.com} +TM_ORG_PRIVACY_POLICY_URL=${TM_ORG_PRIVACY_POLICY_URL:-https://www.hotosm.org/privacy} +TM_ORG_TWITTER=${TM_ORG_TWITTER:-http://twitter.com/hotosm} +TM_ORG_FB=${TM_ORG_FB:-https://www.facebook.com/hotosm} +TM_ORG_INSTAGRAM=${TM_ORG_INSTAGRAM:-https://www.instagram.com/open.mapping.hubs/} +TM_ORG_YOUTUBE=${TM_ORG_YOUTUBE:-https://www.youtube.com/user/hotosm} +TM_ORG_GITHUB=${TM_ORG_GITHUB:-https://github.com/hotosm} # Information about the OSM server - Customize your server here # By default, it's the public OpenStreetMap.org server -OSM_SERVER_URL=https://www.openstreetmap.org -OSM_SERVER_API_URL=https://api.openstreetmap.org -OSM_NOMINATIM_SERVER_URL=https://nominatim.openstreetmap.org -OSM_REGISTER_URL=https://www.openstreetmap.org/user/new +OSM_SERVER_URL=${OSM_SERVER_URL:-https://www.openstreetmap.org} +OSM_SERVER_API_URL=${OSM_SERVER_API_URL:-https://api.openstreetmap.org} +OSM_NOMINATIM_SERVER_URL=${OSM_NOMINATIM_SERVER_URL:-https://nominatim.openstreetmap.org} +OSM_REGISTER_URL=${OSM_REGISTER_URL:-https://www.openstreetmap.org/user/new} # Information about the Editor URLs. Those are the default values on the frontend. # You only need to modify it in case you want to direct users to map on a different OSM instance. -# ID_EDITOR_URL=https://www.openstreetmap.org/edit?editor=id& -# POTLATCH2_EDITOR_URL=https://www.openstreetmap.org/edit?editor=potlatch2 -# RAPID_EDITOR_URL=https://mapwith.ai/rapid +# ID_EDITOR_URL=${ID_EDITOR_URL:-https://www.openstreetmap.org/edit?editor=id&} +# POTLATCH2_EDITOR_URL=${POTLATCH2_EDITOR_URL:-https://www.openstreetmap.org/edit?editor=potlatch2} +# RAPID_EDITOR_URL=${RAPID_EDITOR_URL:-https://mapwith.ai/rapid} # Matomo configuration. Optional, configure it in case you have a Matomo instance. -# TM_MATOMO_ID="site_id" -# TM_MATOMO_ENDPOINT="https://..." +# TM_MATOMO_ID=${TM_MATOMO_ID:-"site_id"} +# TM_MATOMO_ENDPOINT=${TM_MATOMO_ENDPOINT:-"https://..."} # Mapbox access key to display the maps (optional) # @@ -59,25 +59,25 @@ OSM_REGISTER_URL=https://www.openstreetmap.org/user/new # If you do not set a token, then maps will fallback to using the raster tile based # Humanitarian Layer. # -# TM_MAPBOX_TOKEN= +# TM_MAPBOX_TOKEN=${TM_MAPBOX_TOKEN} # If you want your TM app to work better offline and load faster, you can change # from 0 (unregister) to 1 (register) below. Note this comes with some pitfalls. # Learn more about service workers: https://bit.ly/CRA-PWA # It is more complex to use for TM if your frontend and backend are on same server. -# TM_ENABLE_SERVICEWORKER=0 +# TM_ENABLE_SERVICEWORKER=${TM_ENABLE_SERVICEWORKER:-0} # Define an API URL and KEY of an image upload service. # It will be used to store the Organisation logos and the images uploaded on comments input fields. # HOT uses this service: https://github.com/hotosm/cdn-upload-api/ to setup an image upload API -# TM_IMAGE_UPLOAD_API_URL= -# TM_IMAGE_UPLOAD_API_KEY= +# TM_IMAGE_UPLOAD_API_URL=${TM_IMAGE_UPLOAD_API_URL} +# TM_IMAGE_UPLOAD_API_KEY=${TM_IMAGE_UPLOAD_API_KEY} # Define the image to be used on the homepage main's banner. # If it's not defined, the default images will be used. # The high resolution image should be 2500px width. The low should be 824px. -# TM_HOMEPAGE_IMG_HIGH=https://cdn.img.url/banner-high.png -# TM_HOMEPAGE_IMG_LOW=https://cdn.img.url/banner-low.png +# TM_HOMEPAGE_IMG_HIGH=${TM_HOMEPAGE_IMG_HIGH:-https://cdn.img.url/banner-high.png} +# TM_HOMEPAGE_IMG_LOW=${TM_HOMEPAGE_IMG_LOW:-https://cdn.img.url/banner-low.png} # Define a video to be played on the background of the homepage's main banner. # On HOT instance we use https://cdn.hotosm.org/tasking-manager/mapping.mp4 @@ -87,9 +87,9 @@ OSM_REGISTER_URL=https://www.openstreetmap.org/user/new # API base URL and token(used to retrieve user stats only) for ohsomeNow Stats # -OHSOME_STATS_BASE_URL=https://stats.now.ohsome.org -OHSOME_STATS_API_URL=https://stats.now.ohsome.org/api -OHSOME_STATS_TOKEN=testSuperSecretTestToken +OHSOME_STATS_BASE_URL=${OHSOME_STATS_BASE_URL:-https://stats.now.ohsome.org} +OHSOME_STATS_API_URL=${OHSOME_STATS_API_URL:-https://stats.now.ohsome.org/api} +OHSOME_STATS_TOKEN=${OHSOME_STATS_TOKEN:-testSuperSecretTestToken} # Endpoint for OHM's homepage stats # @@ -100,32 +100,32 @@ TM_HOMEPAGE_STATS_API_URL=https://tm-api.staging.openhistoricalmap.org/api/v4/sy # A freely definable secret. Gives authorization to the front- and and back-end # to talk to each other. # -TM_SECRET=s0m3l0ngr4nd0mstr1ng-b3cr34tiv3 +TM_SECRET=${TM_SECRET:-s0m3l0ngr4nd0mstr1ng-b3cr34tiv3} # OpenStreetMap OAuth2 client id and secret (required) # -TM_CLIENT_ID=foo -TM_CLIENT_SECRET=s0m3l0ngr4nd0mstr1ng-b3cr34tiv3 +TM_CLIENT_ID=${TM_CLIENT_ID:-foo} +TM_CLIENT_SECRET=${TM_CLIENT_SECRET:-s0m3l0ngr4nd0mstr1ng-b3cr34tiv3} # Redirect uri registered while creating OAuth2 application (required) -TM_REDIRECT_URI=http://127.0.0.1:3000/authorized +TM_REDIRECT_URI=${TM_REDIRECT_URI:-http://127.0.0.1:3000/authorized} # Scope of TM defined while creating OAuth2 application (required) -TM_SCOPE=read_prefs write_api +TM_SCOPE=${TM_SCOPE:-read_prefs write_api} # Required by requests_oauthlib to work while making oauth2 requests from http server (required) -# OAUTHLIB_INSECURE_TRANSPORT = 1 +# OAUTHLIB_INSECURE_TRANSPORT=${OAUTHLIB_INSECURE_TRANSPORT:-1} # The default tag used in the OSM changeset comment # IMPORTANT! This must be unique on your instance # -# TM_DEFAULT_CHANGESET_COMMENT="#{nameofyourorganisation}-project" +# TM_DEFAULT_CHANGESET_COMMENT=${TM_DEFAULT_CHANGESET_COMMENT:-"#{nameofyourorganisation}-project"} -#################################################### +# ################################################### # -# DATABASE CONNECTION PARAMETERS +# DATABASE CONNECTION PARAMETERS # -#################################################### +# ################################################### # The connection to the postgres database (required) # # The parameter DB_CONNECT_PARAM_JSON needs to be a JSON string readable by @@ -136,39 +136,39 @@ TM_SCOPE=read_prefs write_api # DB_CONNECT_PARAM_JSON='{ "username": "tm", "password": "myprivatesecret", "host": "tm4-database.example.org", "port": "5432", "dbname": "taskingmanager }' # # NOTE: These are ignored if DB_CONNECT_PARAM_JSON is set -POSTGRES_DB=tasking-manager -POSTGRES_USER=tm -POSTGRES_PASSWORD=tm -POSTGRES_ENDPOINT=tm-db -POSTGRES_PORT=5432 +POSTGRES_DB=${POSTGRES_DB:-tasking-manager} +POSTGRES_USER=${POSTGRES_USER:-tm} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-tm} +POSTGRES_ENDPOINT=${POSTGRES_ENDPOINT:-tm-db} +POSTGRES_PORT=${POSTGRES_PORT:-5432} # The postgres database name used for testing (required). # All other configurations except the database name are inherited from the main database defined above. -POSTGRES_TEST_DB=tasking-manager-test +POSTGRES_TEST_DB=${POSTGRES_TEST_DB:-taskingmanagertest} # The address to use as the sender on auto generated emails # (optional, but required to send email) # -# TM_EMAIL_FROM_ADDRESS=noreply@localhost +# TM_EMAIL_FROM_ADDRESS=${TM_EMAIL_FROM_ADDRESS:-noreply@localhost} # The address to use as the receiver in contact form. # -# TM_EMAIL_CONTACT_ADDRESS=sysadmin@localhost +# TM_EMAIL_CONTACT_ADDRESS=${TM_EMAIL_CONTACT_ADDRESS:-sysadmin@localhost} # Email sending server configuration (optional) # This is required in order to send out messages. # -# TM_SMTP_HOST= -# TM_SMTP_PORT=25 -# TM_SMTP_USER= -# TM_SMTP_PASSWORD= +TM_SMTP_HOST=${TM_SMTP_HOST:-smtp.gmail.com} +TM_SMTP_PORT=${TM_SMTP_PORT:-587} +TM_SMTP_USER=${TM_SMTP_USER:-enter-mail@gmail.com} +TM_SMTP_PASSWORD=${TM_SMTP_PASSWORD:-enter-app-password} # Following two variables can have value of either 0 or 1 -# TM_SMTP_USE_TLS=0 -# TM_SMTP_USE_SSL=1 +# TM_SMTP_USE_TLS=${TM_SMTP_USE_TLS:-0} +# TM_SMTP_USE_SSL=${TM_SMTP_USE_SSL:-1} # If disabled project update emails will not be sent. # Set it disabled in case of testing instances -TM_SEND_PROJECT_EMAIL_UPDATES = 1 +TM_SEND_PROJECT_EMAIL_UPDATES=${TM_SEND_PROJECT_EMAIL_UPDATES:-1} # TM_SERVICE_DESK # If the organisation has a service desk, configures the link @@ -179,41 +179,44 @@ TM_SEND_PROJECT_EMAIL_UPDATES = 1 # (e.g. ERROR, DEBUG, etc.) # If not specified DEBUG is default. ERROR is a good value for a live site. # -# TM_LOG_LEVEL=DEBUG -# TM_LOG_DIR=logs +# TM_LOG_LEVEL=${TM_LOG_LEVEL:-DEBUG} +# TM_LOG_DIR=${TM_LOG_DIR:-logs} # Languages settings for the Tasking Manager # -TM_DEFAULT_LOCALE=en +TM_DEFAULT_LOCALE=${TM_DEFAULT_LOCALE:-en} # By default all available languages are shown. You can restrict languages by modifying the following two variables. # Please note that there must be exactly the same number of codes as languages. # -# TM_SUPPORTED_LANGUAGES_CODES="ar, cs, de, el, en, es, fa_IR, fr, he, hu, id, it, ja, ko, mg, ml, nl_NL, pt, pt_BR, ru, sv, sw, tl, tr, uk, zh_TW" -# TM_SUPPORTED_LANGUAGES="عربى, Čeština, Deutsch, Ελληνικά, English, Español, فارسی, Français, עברית, Magyar, Indonesia, Italiano, 日本語, 한국어, Malagasy, Malayalam, Nederlands, Português, Português (Brasil), Русский язык, Svenska, Kiswahili, Filipino (Tagalog), Türkçe, Українська, 繁體中文" +# TM_SUPPORTED_LANGUAGES_CODES=${TM_SUPPORTED_LANGUAGES_CODES:-"ar, cs, de, el, en, es, fa_IR, fr, he, hu, id, it, ja, ko, mg, ml, nl_NL, pt, pt_BR, ru, sv, sw, tl, tr, uk, zh_TW"} +# TM_SUPPORTED_LANGUAGES=${TM_SUPPORTED_LANGUAGES:-"عربى, Čeština, Deutsch, Ελληνικά, English, Español, فارسی, Français, עברית, Magyar, Indonesia, Italiano, 日本語, 한국어, Malagasy, Malayalam, Nederlands, Português, Português (Brasil), Русский язык, Svenska, Kiswahili, Filipino (Tagalog), Türkçe, Українська, 繁體中文"} # Time to wait until task auto-unlock (optional) # (e.g. '2h' or '7d' or '30m' or '1h30m') # -# TM_TASK_AUTOUNLOCK_AFTER=2h +# TM_TASK_AUTOUNLOCK_AFTER=${TM_TASK_AUTOUNLOCK_AFTER:-2h} # Mapper Level values represent number of OSM changesets (optional) # -# TM_MAPPER_LEVEL_INTERMEDIATE=250 -# TM_MAPPER_LEVEL_ADVANCED=500 +# TM_MAPPER_LEVEL_INTERMEDIATE=${TM_MAPPER_LEVEL_INTERMEDIATE:-250} +# TM_MAPPER_LEVEL_ADVANCED=${TM_MAPPER_LEVEL_ADVANCED:-500} # This sets a file size limit to allow when importing a project geometry from a file. Define it in bytes. -# TM_IMPORT_MAX_FILESIZE=1000000 +# TM_IMPORT_MAX_FILESIZE=${TM_IMPORT_MAX_FILESIZE:-1000000} # Defines the maximum area allowed to the Projects' AoI. Default is 5000. The unit is square kilometers. -# TM_MAX_AOI_AREA=5000 +# TM_MAX_AOI_AREA=${TM_MAX_AOI_AREA:-5000} # Sentry.io DSN Config (optional) -# TM_SENTRY_BACKEND_DSN=https://foo.ingest.sentry.io/1234567 -# TM_SENTRY_FRONTEND_DSN=https://bar.ingest.sentry.io/8901234 - -# Underpass API URL (for project live monitoring feature) -UNDERPASS_URL=https://underpass.hotosm.org - -#EXPORT TOOL Integration with 0(Disable) and 1(Enable) and S3 URL for Export Tool -#EXPORT_TOOL_S3_URL=https://foorawdataapi.s3.amazonaws.com -#ENABLE_EXPORT_TOOL=0 +# TM_SENTRY_BACKEND_DSN=${TM_SENTRY_BACKEND_DSN:-https://foo.ingest.sentry.io/1234567} +# TM_SENTRY_FRONTEND_DSN=${TM_SENTRY_FRONTEND_DSN:-https://bar.ingest.sentry.io/8901234} + +# EXPORT TOOL Integration with 0(Disable) and 1(Enable) and S3 URL for Export Tool +# EXPORT_TOOL_S3_URL=${EXPORT_TOOL_S3_URL:-https://foorawdataapi.s3.amazonaws.com} +# ENABLE_EXPORT_TOOL=${ENABLE_EXPORT_TOOL:-0} +PROFILING=${PROFILING:-False} +USE_SENTRY=${USE_SENTRY:-false} + +# Default validator team id for automatic populating validator team +# `HOT Global Validators` in case of HOTOSM +DEFAULT_VALIDATOR_TEAM_ID=${DEFAULT_VALIDATOR_TEAM_ID} diff --git a/frontend/.env.expand b/frontend/.env.expand index 53af9ca981..b9e14f359b 100644 --- a/frontend/.env.expand +++ b/frontend/.env.expand @@ -31,9 +31,7 @@ REACT_APP_ENABLE_SERVICEWORKER=$TM_ENABLE_SERVICEWORKER REACT_APP_MAX_FILESIZE=$TM_IMPORT_MAX_FILESIZE REACT_APP_MAX_AOI_AREA=$TM_MAX_AOI_AREA REACT_APP_OHSOME_STATS_BASE_URL=$OHSOME_STATS_BASE_URL -REACT_APP_OHSOME_STATS_TOKEN=$OHSOME_STATS_TOKEN REACT_APP_OSM_CLIENT_ID=$TM_CLIENT_ID -REACT_APP_OSM_CLIENT_SECRET=$TM_CLIENT_SECRET REACT_APP_OSM_REDIRECT_URI=$TM_REDIRECT_URI REACT_APP_OSM_SERVER_URL=$OSM_SERVER_URL REACT_APP_OSM_SERVER_API_URL=$OSM_SERVER_API_URL @@ -47,3 +45,4 @@ REACT_APP_TM_DEFAULT_CHANGESET_COMMENT=$TM_DEFAULT_CHANGESET_COMMENT REACT_APP_RAPID_EDITOR_URL=$RAPID_EDITOR_URL REACT_APP_EXPORT_TOOL_S3_URL=$EXPORT_TOOL_S3_URL REACT_APP_ENABLE_EXPORT_TOOL=$ENABLE_EXPORT_TOOL +REACT_APP_DEFAULT_VALIDATOR_TEAM_ID=$DEFAULT_VALIDATOR_TEAM_ID diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100755 index 0000000000..e857c26a8d --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -ex + +echo "Installing node modules..." +yarn + +# run script in CMD +exec "$@" diff --git a/frontend/package.json b/frontend/package.json index de4e96b465..c1eb95b9c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,13 +6,12 @@ "dependencies": { "@hotosm/id": "^2.30.2", "@hotosm/iso-countries-languages": "^1.1.2", - "@hotosm/underpass-ui": "^0.0.4", "@mapbox/mapbox-gl-draw": "^1.4.3", "@mapbox/mapbox-gl-geocoder": "^5.0.2", "@mapbox/mapbox-gl-language": "^0.10.1", "@openhistoricalmap/id": "^2.29.2", "@placemarkio/geo-viewport": "^1.0.2", - "@rapideditor/rapid": "^2.4.2", + "@rapideditor/rapid": "^2.5.2", "@sentry/react": "^7.102.0", "@tanstack/react-query": "^4.29.7", "@tanstack/react-query-devtools": "^4.29.7", @@ -84,9 +83,9 @@ "scripts": { "build-locales": "combine-messages -i './src/**/messages.js' -o './src/locales/en.json'", "copy-static": "bash -c \"mkdir -p public/static/rapid; if ! (test -a public/static/rapid/rapid.js); then cp -R node_modules/@rapideditor/rapid/dist/* public/static/rapid; fi\"", - "copy-id-static": "bash -c \"mkdir -p public/static/id; if ! (test -a public/static/id/data); then cp -R node_modules/@openhistoricalmap/id/dist/data public/static/id && cp -R node_modules/@openhistoricalmap/id/dist/img public/static/id && cp -R node_modules/@openhistoricalmap/id/dist/locales public/static/id; fi;\"", + "copy-id-static": "bash -c \"mkdir -p public/static/id; if ! (test -a public/static/id/data); then cp -R node_modules/@openhistoricalmap/id/dist/* public/static/id; fi;\"", "update-static": "bash -c \"mkdir -p public/static/rapid; cp -R node_modules/@rapideditor/rapid/dist/* public/static/rapid;\"", - "update-id-static": "bash -c \"mkdir -p public/static/id; cp -R node_modules/@openhistoricalmap/id/dist/data public/static/id && cp -R node_modules/@openhistoricalmap/id/dist/img public/static/id && cp -R node_modules/@openhistoricalmap/id/dist/locales public/static/id;\"", + "update-id-static": "bash -c \"mkdir -p public/static/id; cp -R node_modules/@openhistoricalmap/id/dist/* public/static/id;\"", "patch-rapid": "bash -c \"cp patch/rapid-imagery.min.json public/static/rapid/data/imagery.min.json\"", "preparation": "bash -c \"if (test -a ../tasking-manager.env); then grep -hs ^ ../tasking-manager.env .env.expand > .env; else cp .env.expand .env; fi\"", "start": "npm run preparation && npm run copy-static && npm run copy-id-static && npm run patch-rapid && craco start", diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js index fc8083477a..8c076f7ffd 100644 --- a/frontend/src/api/projects.js +++ b/frontend/src/api/projects.js @@ -1,11 +1,9 @@ -import axios from 'axios'; import { subMonths, format } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { remapParamsToAPI } from '../utils/remapParamsToAPI'; import api from './apiClient'; -import { UNDERPASS_URL } from '../config'; export const useProjectsQuery = (fullProjectsQuery, action, queryOptions) => { const token = useSelector((state) => state.auth.token); @@ -209,18 +207,6 @@ export const downloadAsCSV = (allQueryParams, action, token) => { }); }; -export const useAvailableCountriesQuery = () => { - const fetchGeojsonData = () => { - return axios.get(`${UNDERPASS_URL}/availability`); - }; - - return useQuery({ - queryKey: ['priority-geojson'], - queryFn: fetchGeojsonData, - select: (res) => res.data, - }); -}; - export const useAllPartnersQuery = (token, userId) => { const fetchAllPartners = () => { return api(token).get('partners/'); diff --git a/frontend/src/components/editor.js b/frontend/src/components/editor.js index d6128de15b..00f5c7ddd2 100644 --- a/frontend/src/components/editor.js +++ b/frontend/src/components/editor.js @@ -5,7 +5,7 @@ import { gpx } from '@tmcw/togeojson'; import * as iD from '@openhistoricalmap/id'; import '@openhistoricalmap/id/dist/iD.css'; -import { OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_SERVER_URL } from '../config'; +import { OSM_CLIENT_ID, OSM_REDIRECT_URI, OSM_SERVER_URL } from '../config'; import messages from './messages'; export default function Editor({ setDisable, comment, presets, imagery, gpxUrl }) { @@ -99,7 +99,6 @@ export default function Editor({ setDisable, comment, presets, imagery, gpxUrl } var auth = { url: OSM_SERVER_URL, client_id: OSM_CLIENT_ID, - client_secret: OSM_CLIENT_SECRET, redirect_uri: OSM_REDIRECT_URI, access_token: session.osm_oauth_token, }; diff --git a/frontend/src/components/footer/index.js b/frontend/src/components/footer/index.js index bc4dbdda36..8a31ddb75a 100644 --- a/frontend/src/components/footer/index.js +++ b/frontend/src/components/footer/index.js @@ -40,7 +40,6 @@ export function Footer() { 'projects/:id/contributions', 'projects/:id/map', 'projects/:id/validate', - 'projects/:id/live', 'manage/organisations/new/', 'manage/teams/new', 'manage/campaigns/new', diff --git a/frontend/src/components/footer/tests/index.test.js b/frontend/src/components/footer/tests/index.test.js index f1f8951248..89727b71bc 100644 --- a/frontend/src/components/footer/tests/index.test.js +++ b/frontend/src/components/footer/tests/index.test.js @@ -119,7 +119,7 @@ describe('Footer', () => { screen.getByRole('link', { name: messages.learn.defaultMessage, }), - ).toHaveAttribute('href', 'https://osm.org/about'); + ).toHaveAttribute('href', 'https://www.openhistoricalmap.org/about'); }); it('should not display foooter for specified URLs', () => { diff --git a/frontend/src/components/header/index.js b/frontend/src/components/header/index.js index b46c4e0184..0ac589ff93 100644 --- a/frontend/src/components/header/index.js +++ b/frontend/src/components/header/index.js @@ -18,7 +18,7 @@ import { SignUp } from './signUp'; import { UpdateEmail } from './updateEmail'; import { CurrentUserAvatar } from '../user/avatar'; import { logout } from '../../store/actions/auth'; -import { createLoginWindow } from '../../utils/login'; +import { osmLoginRedirect } from '../../utils/login'; import { NotificationBell } from './notificationBell'; import { useDebouncedCallback } from '../../hooks/UseThrottle'; import { HorizontalScroll } from '../horizontalScroll'; @@ -147,7 +147,7 @@ export const Header = () => {
{ - const [debouncedCreateLoginWindow] = useDebouncedCallback( - (redirectToPass) => createLoginWindow(redirectToPass), + const [debouncedOsmLoginRedirect] = useDebouncedCallback( + (redirectToPass) => osmLoginRedirect(redirectToPass), 3000, { leading: true }, ); return ( <> - { } closeModal(); - createLoginWindow(redirect); + osmLoginRedirect(redirect); }; const getStep = (step) => { diff --git a/frontend/src/components/header/tests/index.test.js b/frontend/src/components/header/tests/index.test.js index 87cfaf1c45..5eec44559c 100644 --- a/frontend/src/components/header/tests/index.test.js +++ b/frontend/src/components/header/tests/index.test.js @@ -36,7 +36,7 @@ describe('Header', () => { if (ORG_LOGO) { expect(orgLogo).toHaveAttribute('src', ORG_LOGO); } else { - expect(orgLogo).toHaveAttribute('src', 'main-logo.svg'); + expect(orgLogo).toHaveAttribute('src', 'main-logo-ohm.svg'); } expect(screen.getByText(/Tasking Manager/i)).toBeInTheDocument(); ['Explore projects', 'Learn', 'About'].forEach((menuItem) => @@ -134,7 +134,7 @@ describe('Right side action items', () => { expect(screen.getByLabelText('Sample avatar')).toBeInTheDocument(); }); - test("when the user isn't logged in", () => { + test("when the user isn't logged in", async () => { act(() => { store.dispatch({ type: 'SET_USER_DETAILS', @@ -142,8 +142,10 @@ describe('Right side action items', () => { }); }); setup(); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getByText('English')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + }); expect( screen.getByRole('button', { name: /log in/i, diff --git a/frontend/src/components/localeSelect.js b/frontend/src/components/localeSelect.js index 5c716723a9..cd4ad153f1 100644 --- a/frontend/src/components/localeSelect.js +++ b/frontend/src/components/localeSelect.js @@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl'; import Select from 'react-select'; import messages from './messages'; -import { supportedLocales } from '../utils/internationalization'; +import { useFetch } from '../hooks/UseFetch'; import { setLocale } from '../store/actions/userPreferences'; function LocaleSelect({ @@ -13,22 +13,30 @@ function LocaleSelect({ removeBorder = true, fullWidth = false, }) { + const [errorLanguages, loadingLanguages, languages] = useFetch('system/languages/'); + + const supportedLanguages = + !errorLanguages && !loadingLanguages ? languages.supportedLanguages : []; + const onLocaleSelect = (arr) => { - setLocale(arr[0].value); + setLocale(arr[0].code); }; const getActiveLanguageNames = () => { const locales = [userPreferences.locale, navigator.language, navigator.language.substr(0, 2)]; let supportedLocaleNames = []; locales.forEach((locale) => - supportedLocales - .filter((i) => i.value === locale) + supportedLanguages + .filter((i) => i.code === locale) .forEach((i) => supportedLocaleNames.push(i)), ); - return supportedLocaleNames.length ? supportedLocaleNames[0].value : 'en'; + return supportedLocaleNames.length ? supportedLocaleNames[0].code : 'en'; }; + // wait till supportedLanguages are fetched + if (!supportedLanguages.length) return <>; + return (
-   - - - { - setRealtimeList(!realtimeList); - }} - name="liveListCheckbox" - type="checkbox" - /> - - { - setRealtimeMap(!realtimeMap); - }} - name="liveMapCheckbox" - type="checkbox" - /> - - { - setListAll(!listAll); - }} - name="listAllCheckbox" - type="checkbox" - /> - - -
- { - setCoords([feature.lat, feature.lon]); - const tags = JSON.stringify(feature.tags); - const status = feature.status; - setActiveFeature({ properties: { tags, status }, ...feature }); - }} - realtime={realtimeList} - config={underpassConfig} - status={listAll ? '' : status} - orderBy="created_at" - onFetchFirstTime={(mostRecentFeature) => { - if (mostRecentFeature) { - setCoords([mostRecentFeature.lat, mostRecentFeature.lon]); - } - }} - /> - - )} -
- - - - ); -} - -export default ProjectLiveMonitoring; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 621d9720fd..92131afe79 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3,9 +3,9 @@ "@adobe/css-tools@^4.0.1": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.2.tgz#c836b1bd81e6d62cd6cdf3ee4948bcdce8ea79c8" - integrity sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A== + version "4.4.3" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.3.tgz#beebbefb0264fdeb32d3052acae0e0d94315a9a2" + integrity sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA== "@aitodotai/json-stringify-pretty-compact@^1.3.0": version "1.3.0" @@ -34,35 +34,35 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.8.3": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" - integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.8.3": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== dependencies: - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-validator-identifier" "^7.27.1" js-tokens "^4.0.0" - picocolors "^1.0.0" + picocolors "^1.1.1" -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.5", "@babel/compat-data@^7.26.8": - version "7.26.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" - integrity sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ== +"@babel/compat-data@^7.27.2", "@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.0.tgz#9fc6fd58c2a6a15243cd13983224968392070790" + integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw== "@babel/core@^7.1.0", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0", "@babel/core@^7.5.5", "@babel/core@^7.7.2", "@babel/core@^7.8.0": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.10.tgz#5c876f83c8c4dcb233ee4b670c0606f2ac3000f9" - integrity sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ== + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.0.tgz#55dad808d5bf3445a108eefc88ea3fdf034749a4" + integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ== dependencies: "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.26.10" - "@babel/helper-compilation-targets" "^7.26.5" - "@babel/helper-module-transforms" "^7.26.0" - "@babel/helpers" "^7.26.10" - "@babel/parser" "^7.26.10" - "@babel/template" "^7.26.9" - "@babel/traverse" "^7.26.10" - "@babel/types" "^7.26.10" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.0" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.27.3" + "@babel/helpers" "^7.27.6" + "@babel/parser" "^7.28.0" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.0" + "@babel/types" "^7.28.0" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -70,216 +70,221 @@ semver "^6.3.1" "@babel/eslint-parser@^7.16.3": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.26.10.tgz#4423cb3f84c26978439feabfe23c5aa929400737" - integrity sha512-QsfQZr4AiLpKqn7fz+j7SN+f43z2DZCgGyYbNJ2vJOqKfG4E6MZer1+jqGZqKJaxq/gdO2DC/nUu45+pOL5p2Q== + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz#c1b3fbba070f5bac32e3d02f244201add4afdd6e" + integrity sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w== dependencies: "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1" eslint-visitor-keys "^2.1.0" semver "^6.3.1" -"@babel/generator@^7.26.10", "@babel/generator@^7.7.2": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.10.tgz#a60d9de49caca16744e6340c3658dfef6138c3f7" - integrity sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang== +"@babel/generator@^7.28.0", "@babel/generator@^7.7.2": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.0.tgz#9cc2f7bd6eb054d77dc66c2664148a0c5118acd2" + integrity sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg== dependencies: - "@babel/parser" "^7.26.10" - "@babel/types" "^7.26.10" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" + "@babel/parser" "^7.28.0" + "@babel/types" "^7.28.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" - integrity sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g== +"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== dependencies: - "@babel/types" "^7.25.9" + "@babel/types" "^7.27.3" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9", "@babel/helper-compilation-targets@^7.26.5": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8" - integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA== +"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== dependencies: - "@babel/compat-data" "^7.26.5" - "@babel/helper-validator-option" "^7.25.9" + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" browserslist "^4.24.0" lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.25.9": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz#d6f83e3039547fbb39967e78043cd3c8b7820c71" - integrity sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/helper-replace-supers" "^7.26.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/traverse" "^7.26.9" +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz#5bee4262a6ea5ddc852d0806199eb17ca3de9281" + integrity sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.27.1" semver "^6.3.1" -"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.25.9": - version "7.26.3" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz#5169756ecbe1d95f7866b90bb555b022595302a0" - integrity sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong== +"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz#05b0882d97ba1d4d03519e4bce615d70afa18c53" + integrity sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.1" regexpu-core "^6.2.0" semver "^6.3.1" -"@babel/helper-define-polyfill-provider@^0.6.3", "@babel/helper-define-polyfill-provider@^0.6.4": - version "0.6.4" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz#15e8746368bfa671785f5926ff74b3064c291fab" - integrity sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw== +"@babel/helper-define-polyfill-provider@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz#742ccf1cb003c07b48859fc9fa2c1bbe40e5f753" + integrity sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg== dependencies: - "@babel/helper-compilation-targets" "^7.22.6" - "@babel/helper-plugin-utils" "^7.22.5" - debug "^4.1.1" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + debug "^4.4.1" lodash.debounce "^4.0.8" - resolve "^1.14.2" - -"@babel/helper-member-expression-to-functions@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3" - integrity sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" - integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" - integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== - dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" - -"@babel/helper-optimise-call-expression@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz#3324ae50bae7e2ab3c33f60c9a877b6a0146b54e" - integrity sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ== - dependencies: - "@babel/types" "^7.25.9" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.26.5", "@babel/helper-plugin-utils@^7.8.0": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35" - integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg== - -"@babel/helper-remap-async-to-generator@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz#e53956ab3d5b9fb88be04b3e2f31b523afd34b92" - integrity sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-wrap-function" "^7.25.9" - "@babel/traverse" "^7.25.9" - -"@babel/helper-replace-supers@^7.25.9", "@babel/helper-replace-supers@^7.26.5": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz#6cb04e82ae291dae8e72335dfe438b0725f14c8d" - integrity sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/traverse" "^7.26.5" - -"@babel/helper-skip-transparent-expression-wrappers@^7.20.0", "@babel/helper-skip-transparent-expression-wrappers@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz#0b2e1b62d560d6b1954893fd2b705dc17c91f0c9" - integrity sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-string-parser@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" - integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + resolve "^1.22.10" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-member-expression-to-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz#ea1211276be93e798ce19037da6f06fbb994fa44" + integrity sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz#db0bbcfba5802f9ef7870705a7ef8788508ede02" + integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.3" + +"@babel/helper-optimise-call-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200" + integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw== + dependencies: + "@babel/types" "^7.27.1" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-remap-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6" + integrity sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-wrap-function" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/helper-replace-supers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz#b1ed2d634ce3bdb730e4b52de30f8cccfd692bc0" + integrity sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/helper-skip-transparent-expression-wrappers@^7.20.0", "@babel/helper-skip-transparent-expression-wrappers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56" + integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helper-wrap-function@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz#b88285009c31427af318d4fe37651cd62a142409" + integrity sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ== + dependencies: + "@babel/template" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helpers@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.6.tgz#6456fed15b2cb669d2d1fabe84b66b34991d812c" + integrity sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.6" -"@babel/helper-validator-identifier@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" - integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== - -"@babel/helper-validator-option@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" - integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== - -"@babel/helper-wrap-function@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz#d99dfd595312e6c894bd7d237470025c85eea9d0" - integrity sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.0.tgz#979829fbab51a29e13901e5a80713dbcb840825e" + integrity sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g== dependencies: - "@babel/template" "^7.25.9" - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helpers@^7.26.10": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384" - integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g== + "@babel/types" "^7.28.0" + +"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz#61dd8a8e61f7eb568268d1b5f129da3eee364bf9" + integrity sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA== dependencies: - "@babel/template" "^7.26.9" - "@babel/types" "^7.26.10" - -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.26.10", "@babel/parser@^7.26.9": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.10.tgz#e9bdb82f14b97df6569b0b038edd436839c57749" - integrity sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA== + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d" + integrity sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA== dependencies: - "@babel/types" "^7.26.10" - -"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe" - integrity sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/traverse" "^7.25.9" - -"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz#af9e4fb63ccb8abcb92375b2fcfe36b60c774d30" - integrity sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz#e8dc26fcd616e6c5bf2bd0d5a2c151d4f92a9137" - integrity sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72" + integrity sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz#807a667f9158acac6f6164b4beb85ad9ebc9e1d1" - integrity sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g== +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd" + integrity sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/plugin-transform-optional-chaining" "^7.25.9" - -"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz#de7093f1e7deaf68eadd7cc6b07f2ab82543269e" - integrity sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" + +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz#bb1c25af34d75115ce229a1de7fa44bf8f955670" + integrity sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" "@babel/plugin-proposal-class-properties@^7.16.0": version "7.18.6" @@ -290,13 +295,13 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-proposal-decorators@^7.16.4": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.25.9.tgz#8680707f943d1a3da2cd66b948179920f097e254" - integrity sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g== + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz#419c8acc31088e05a774344c021800f7ddc39bf0" + integrity sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/plugin-syntax-decorators" "^7.25.9" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-decorators" "^7.27.1" "@babel/plugin-proposal-nullish-coalescing-operator@^7.16.0": version "7.18.6" @@ -374,33 +379,33 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-decorators@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz#986b4ca8b7b5df3f67cee889cedeffc2e2bf14b3" - integrity sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg== +"@babel/plugin-syntax-decorators@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz#ee7dd9590aeebc05f9d4c8c0560007b05979a63d" + integrity sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-flow@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.26.0.tgz#96507595c21b45fccfc2bc758d5c45452e6164fa" - integrity sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg== +"@babel/plugin-syntax-flow@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz#6c83cf0d7d635b716827284b7ecd5aead9237662" + integrity sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-import-assertions@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz#620412405058efa56e4a564903b79355020f445f" - integrity sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg== +"@babel/plugin-syntax-import-assertions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz#88894aefd2b03b5ee6ad1562a7c8e1587496aecd" + integrity sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-import-attributes@^7.24.7", "@babel/plugin-syntax-import-attributes@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz#3b1412847699eea739b4f2602c74ce36f6b0b0f7" - integrity sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A== +"@babel/plugin-syntax-import-attributes@^7.24.7", "@babel/plugin-syntax-import-attributes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" @@ -416,12 +421,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz#a34313a178ea56f1951599b929c1ceacee719290" - integrity sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA== +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" @@ -479,12 +484,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-typescript@^7.25.9", "@babel/plugin-syntax-typescript@^7.7.2": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz#67dda2b74da43727cf21d46cf9afef23f4365399" - integrity sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ== +"@babel/plugin-syntax-typescript@^7.27.1", "@babel/plugin-syntax-typescript@^7.7.2": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" @@ -494,537 +499,548 @@ "@babel/helper-create-regexp-features-plugin" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-arrow-functions@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz#7821d4410bee5daaadbb4cdd9a6649704e176845" - integrity sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg== +"@babel/plugin-transform-arrow-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" + integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-async-generator-functions@^7.26.8": - version "7.26.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz#5e3991135e3b9c6eaaf5eff56d1ae5a11df45ff8" - integrity sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg== +"@babel/plugin-transform-async-generator-functions@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz#1276e6c7285ab2cd1eccb0bc7356b7a69ff842c2" + integrity sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/helper-remap-async-to-generator" "^7.25.9" - "@babel/traverse" "^7.26.8" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/traverse" "^7.28.0" -"@babel/plugin-transform-async-to-generator@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz#c80008dacae51482793e5a9c08b39a5be7e12d71" - integrity sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ== +"@babel/plugin-transform-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz#9a93893b9379b39466c74474f55af03de78c66e7" + integrity sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA== dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-remap-async-to-generator" "^7.25.9" + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" -"@babel/plugin-transform-block-scoped-functions@^7.26.5": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz#3dc4405d31ad1cbe45293aa57205a6e3b009d53e" - integrity sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ== +"@babel/plugin-transform-block-scoped-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9" + integrity sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-block-scoping@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz#c33665e46b06759c93687ca0f84395b80c0473a1" - integrity sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg== +"@babel/plugin-transform-block-scoping@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz#e7c50cbacc18034f210b93defa89638666099451" + integrity sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-properties@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz#a8ce84fedb9ad512549984101fa84080a9f5f51f" - integrity sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q== +"@babel/plugin-transform-class-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz#dd40a6a370dfd49d32362ae206ddaf2bb082a925" + integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-static-block@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz#6c8da219f4eb15cae9834ec4348ff8e9e09664a0" - integrity sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ== +"@babel/plugin-transform-class-static-block@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz#7e920d5625b25bbccd3061aefbcc05805ed56ce4" + integrity sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-classes@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz#7152457f7880b593a63ade8a861e6e26a4469f52" - integrity sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg== +"@babel/plugin-transform-classes@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz#12fa46cffc32a6e084011b650539e880add8a0f8" + integrity sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-compilation-targets" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-replace-supers" "^7.25.9" - "@babel/traverse" "^7.25.9" - globals "^11.1.0" + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/traverse" "^7.28.0" -"@babel/plugin-transform-computed-properties@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz#db36492c78460e534b8852b1d5befe3c923ef10b" - integrity sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA== +"@babel/plugin-transform-computed-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz#81662e78bf5e734a97982c2b7f0a793288ef3caa" + integrity sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/template" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/template" "^7.27.1" -"@babel/plugin-transform-destructuring@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz#966ea2595c498224340883602d3cfd7a0c79cea1" - integrity sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ== +"@babel/plugin-transform-destructuring@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz#0f156588f69c596089b7d5b06f5af83d9aa7f97a" + integrity sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.28.0" -"@babel/plugin-transform-dotall-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz#bad7945dd07734ca52fe3ad4e872b40ed09bb09a" - integrity sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA== +"@babel/plugin-transform-dotall-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz#aa6821de864c528b1fecf286f0a174e38e826f4d" + integrity sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-duplicate-keys@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz#8850ddf57dce2aebb4394bb434a7598031059e6d" - integrity sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw== +"@babel/plugin-transform-duplicate-keys@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1" + integrity sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz#6f7259b4de127721a08f1e5165b852fcaa696d31" - integrity sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog== +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz#5043854ca620a94149372e69030ff8cb6a9eb0ec" + integrity sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-dynamic-import@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz#23e917de63ed23c6600c5dd06d94669dce79f7b8" - integrity sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg== +"@babel/plugin-transform-dynamic-import@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4" + integrity sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-exponentiation-operator@^7.26.3": - version "7.26.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz#e29f01b6de302c7c2c794277a48f04a9ca7f03bc" - integrity sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ== +"@babel/plugin-transform-explicit-resource-management@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz#45be6211b778dbf4b9d54c4e8a2b42fa72e09a1a" + integrity sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.28.0" -"@babel/plugin-transform-export-namespace-from@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz#90745fe55053394f554e40584cda81f2c8a402a2" - integrity sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww== +"@babel/plugin-transform-exponentiation-operator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz#fc497b12d8277e559747f5a3ed868dd8064f83e1" + integrity sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-export-namespace-from@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23" + integrity sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-flow-strip-types@^7.16.0": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.26.5.tgz#2904c85a814e7abb1f4850b8baf4f07d0a2389d4" - integrity sha512-eGK26RsbIkYUns3Y8qKl362juDDYK+wEdPGHGrhzUl6CewZFo55VZ7hg+CyMFU4dd5QQakBN86nBMpRsFpRvbQ== + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz#5def3e1e7730f008d683144fb79b724f92c5cdf9" + integrity sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/plugin-syntax-flow" "^7.26.0" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-flow" "^7.27.1" -"@babel/plugin-transform-for-of@^7.26.9": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz#27231f79d5170ef33b5111f07fe5cafeb2c96a56" - integrity sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg== +"@babel/plugin-transform-for-of@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a" + integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-function-name@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz#939d956e68a606661005bfd550c4fc2ef95f7b97" - integrity sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA== +"@babel/plugin-transform-function-name@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7" + integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ== dependencies: - "@babel/helper-compilation-targets" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" -"@babel/plugin-transform-json-strings@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz#c86db407cb827cded902a90c707d2781aaa89660" - integrity sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw== +"@babel/plugin-transform-json-strings@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz#a2e0ce6ef256376bd527f290da023983527a4f4c" + integrity sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-literals@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz#1a1c6b4d4aa59bc4cad5b6b3a223a0abd685c9de" - integrity sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ== +"@babel/plugin-transform-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24" + integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-logical-assignment-operators@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz#b19441a8c39a2fda0902900b306ea05ae1055db7" - integrity sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q== +"@babel/plugin-transform-logical-assignment-operators@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz#890cb20e0270e0e5bebe3f025b434841c32d5baa" + integrity sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-member-expression-literals@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz#63dff19763ea64a31f5e6c20957e6a25e41ed5de" - integrity sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA== +"@babel/plugin-transform-member-expression-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9" + integrity sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-modules-amd@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz#49ba478f2295101544abd794486cd3088dddb6c5" - integrity sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw== +"@babel/plugin-transform-modules-amd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f" + integrity sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA== dependencies: - "@babel/helper-module-transforms" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-modules-commonjs@^7.25.9", "@babel/plugin-transform-modules-commonjs@^7.26.3": - version "7.26.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz#8f011d44b20d02c3de44d8850d971d8497f981fb" - integrity sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ== +"@babel/plugin-transform-modules-commonjs@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz#8e44ed37c2787ecc23bdc367f49977476614e832" + integrity sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw== dependencies: - "@babel/helper-module-transforms" "^7.26.0" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-modules-systemjs@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz#8bd1b43836269e3d33307151a114bcf3ba6793f8" - integrity sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA== +"@babel/plugin-transform-modules-systemjs@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz#00e05b61863070d0f3292a00126c16c0e024c4ed" + integrity sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA== dependencies: - "@babel/helper-module-transforms" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.1" -"@babel/plugin-transform-modules-umd@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz#6710079cdd7c694db36529a1e8411e49fcbf14c9" - integrity sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw== +"@babel/plugin-transform-modules-umd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334" + integrity sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w== dependencies: - "@babel/helper-module-transforms" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-named-capturing-groups-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz#454990ae6cc22fd2a0fa60b3a2c6f63a38064e6a" - integrity sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA== +"@babel/plugin-transform-named-capturing-groups-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz#f32b8f7818d8fc0cc46ee20a8ef75f071af976e1" + integrity sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-new-target@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz#42e61711294b105c248336dcb04b77054ea8becd" - integrity sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ== +"@babel/plugin-transform-new-target@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb" + integrity sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@^7.26.6": - version "7.26.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz#fbf6b3c92cb509e7b319ee46e3da89c5bedd31fe" - integrity sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw== +"@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz#4f9d3153bf6782d73dd42785a9d22d03197bc91d" + integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-numeric-separator@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz#bfed75866261a8b643468b0ccfd275f2033214a1" - integrity sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q== +"@babel/plugin-transform-numeric-separator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz#614e0b15cc800e5997dadd9bd6ea524ed6c819c6" + integrity sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-object-rest-spread@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz#0203725025074164808bcf1a2cfa90c652c99f18" - integrity sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg== +"@babel/plugin-transform-object-rest-spread@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz#d23021857ffd7cd809f54d624299b8086402ed8d" + integrity sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA== dependencies: - "@babel/helper-compilation-targets" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/plugin-transform-parameters" "^7.25.9" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.28.0" + "@babel/plugin-transform-parameters" "^7.27.7" + "@babel/traverse" "^7.28.0" -"@babel/plugin-transform-object-super@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz#385d5de135162933beb4a3d227a2b7e52bb4cf03" - integrity sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A== +"@babel/plugin-transform-object-super@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5" + integrity sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-replace-supers" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" -"@babel/plugin-transform-optional-catch-binding@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz#10e70d96d52bb1f10c5caaac59ac545ea2ba7ff3" - integrity sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g== +"@babel/plugin-transform-optional-catch-binding@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz#84c7341ebde35ccd36b137e9e45866825072a30c" + integrity sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-optional-chaining@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz#e142eb899d26ef715435f201ab6e139541eee7dd" - integrity sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A== +"@babel/plugin-transform-optional-chaining@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz#874ce3c4f06b7780592e946026eb76a32830454f" + integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-parameters@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz#b856842205b3e77e18b7a7a1b94958069c7ba257" - integrity sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g== +"@babel/plugin-transform-parameters@^7.27.7": + version "7.27.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz#1fd2febb7c74e7d21cf3b05f7aebc907940af53a" + integrity sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-private-methods@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz#847f4139263577526455d7d3223cd8bda51e3b57" - integrity sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw== +"@babel/plugin-transform-private-methods@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz#fdacbab1c5ed81ec70dfdbb8b213d65da148b6af" + integrity sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-private-property-in-object@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz#9c8b73e64e6cc3cbb2743633885a7dd2c385fe33" - integrity sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw== +"@babel/plugin-transform-private-property-in-object@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz#4dbbef283b5b2f01a21e81e299f76e35f900fb11" + integrity sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-property-literals@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz#d72d588bd88b0dec8b62e36f6fda91cedfe28e3f" - integrity sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA== +"@babel/plugin-transform-property-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424" + integrity sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-react-constant-elements@^7.12.1": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.25.9.tgz#08a1de35a301929b60fdf2788a54b46cd8ecd0ef" - integrity sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow== + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz#6c6b50424e749a6e48afd14cf7b92f98cb9383f9" + integrity sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-react-display-name@^7.16.0", "@babel/plugin-transform-react-display-name@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz#4b79746b59efa1f38c8695065a92a9f5afb24f7d" - integrity sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ== +"@babel/plugin-transform-react-display-name@^7.16.0", "@babel/plugin-transform-react-display-name@^7.27.1": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz#6f20a7295fea7df42eb42fed8f896813f5b934de" + integrity sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-react-jsx-development@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz#8fd220a77dd139c07e25225a903b8be8c829e0d7" - integrity sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw== +"@babel/plugin-transform-react-jsx-development@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz#47ff95940e20a3a70e68ad3d4fcb657b647f6c98" + integrity sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q== dependencies: - "@babel/plugin-transform-react-jsx" "^7.25.9" + "@babel/plugin-transform-react-jsx" "^7.27.1" -"@babel/plugin-transform-react-jsx@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz#06367940d8325b36edff5e2b9cbe782947ca4166" - integrity sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw== +"@babel/plugin-transform-react-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz#1023bc94b78b0a2d68c82b5e96aed573bcfb9db0" + integrity sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/plugin-syntax-jsx" "^7.25.9" - "@babel/types" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/types" "^7.27.1" -"@babel/plugin-transform-react-pure-annotations@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz#ea1c11b2f9dbb8e2d97025f43a3b5bc47e18ae62" - integrity sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg== +"@babel/plugin-transform-react-pure-annotations@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz#339f1ce355eae242e0649f232b1c68907c02e879" + integrity sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-regenerator@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz#03a8a4670d6cebae95305ac6defac81ece77740b" - integrity sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg== +"@babel/plugin-transform-regenerator@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz#f19ca3558f7121924fc4ba6cd2afe3a5cdac89b1" + integrity sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - regenerator-transform "^0.15.2" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-regexp-modifiers@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz#2f5837a5b5cd3842a919d8147e9903cc7455b850" - integrity sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw== +"@babel/plugin-transform-regexp-modifiers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz#df9ba5577c974e3f1449888b70b76169998a6d09" + integrity sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-reserved-words@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz#0398aed2f1f10ba3f78a93db219b27ef417fb9ce" - integrity sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg== +"@babel/plugin-transform-reserved-words@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4" + integrity sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-runtime@^7.16.4": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.10.tgz#6b4504233de8238e7d666c15cde681dc62adff87" - integrity sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw== - dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-plugin-utils" "^7.26.5" - babel-plugin-polyfill-corejs2 "^0.4.10" - babel-plugin-polyfill-corejs3 "^0.11.0" - babel-plugin-polyfill-regenerator "^0.6.1" + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.0.tgz#462e79008cc7bdac03e4c5e1765b9de2bcd31c21" + integrity sha512-dGopk9nZrtCs2+nfIem25UuHyt5moSJamArzIoh9/vezUQPmYDOzjaHDCkAzuGJibCIkPup8rMT2+wYB6S73cA== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + babel-plugin-polyfill-corejs2 "^0.4.14" + babel-plugin-polyfill-corejs3 "^0.13.0" + babel-plugin-polyfill-regenerator "^0.6.5" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz#bb785e6091f99f826a95f9894fc16fde61c163f2" - integrity sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng== +"@babel/plugin-transform-shorthand-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" + integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-spread@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz#24a35153931b4ba3d13cec4a7748c21ab5514ef9" - integrity sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A== +"@babel/plugin-transform-spread@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz#1a264d5fc12750918f50e3fe3e24e437178abb08" + integrity sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-sticky-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz#c7f02b944e986a417817b20ba2c504dfc1453d32" - integrity sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA== +"@babel/plugin-transform-sticky-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280" + integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-template-literals@^7.26.8": - version "7.26.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz#966b15d153a991172a540a69ad5e1845ced990b5" - integrity sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q== +"@babel/plugin-transform-template-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" + integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-typeof-symbol@^7.26.7": - version "7.26.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz#d0e33acd9223744c1e857dbd6fa17bd0a3786937" - integrity sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw== +"@babel/plugin-transform-typeof-symbol@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369" + integrity sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw== dependencies: - "@babel/helper-plugin-utils" "^7.26.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-typescript@^7.25.9": - version "7.26.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz#2e9caa870aa102f50d7125240d9dbf91334b0950" - integrity sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw== +"@babel/plugin-transform-typescript@^7.27.1": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz#796cbd249ab56c18168b49e3e1d341b72af04a6b" + integrity sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/plugin-syntax-typescript" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" -"@babel/plugin-transform-unicode-escapes@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz#a75ef3947ce15363fccaa38e2dd9bc70b2788b82" - integrity sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q== +"@babel/plugin-transform-unicode-escapes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806" + integrity sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-property-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz#a901e96f2c1d071b0d1bb5dc0d3c880ce8f53dd3" - integrity sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg== +"@babel/plugin-transform-unicode-property-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz#bdfe2d3170c78c5691a3c3be934c8c0087525956" + integrity sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz#5eae747fe39eacf13a8bd006a4fb0b5d1fa5e9b1" - integrity sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA== +"@babel/plugin-transform-unicode-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" + integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-sets-regex@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz#65114c17b4ffc20fa5b163c63c70c0d25621fabe" - integrity sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ== +"@babel/plugin-transform-unicode-sets-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz#6ab706d10f801b5c72da8bb2548561fa04193cd1" + integrity sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" "@babel/preset-env@^7.11.0", "@babel/preset-env@^7.12.1", "@babel/preset-env@^7.16.4": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.26.9.tgz#2ec64e903d0efe743699f77a10bdf7955c2123c3" - integrity sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ== - dependencies: - "@babel/compat-data" "^7.26.8" - "@babel/helper-compilation-targets" "^7.26.5" - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/helper-validator-option" "^7.25.9" - "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.25.9" - "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.25.9" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.25.9" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.25.9" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.25.9" + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.28.0.tgz#d23a6bc17b43227d11db77081a0779c706b5569c" + integrity sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg== + dependencies: + "@babel/compat-data" "^7.28.0" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.27.1" + "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.27.1" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.27.1" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.27.1" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.27.1" "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-import-assertions" "^7.26.0" - "@babel/plugin-syntax-import-attributes" "^7.26.0" + "@babel/plugin-syntax-import-assertions" "^7.27.1" + "@babel/plugin-syntax-import-attributes" "^7.27.1" "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6" - "@babel/plugin-transform-arrow-functions" "^7.25.9" - "@babel/plugin-transform-async-generator-functions" "^7.26.8" - "@babel/plugin-transform-async-to-generator" "^7.25.9" - "@babel/plugin-transform-block-scoped-functions" "^7.26.5" - "@babel/plugin-transform-block-scoping" "^7.25.9" - "@babel/plugin-transform-class-properties" "^7.25.9" - "@babel/plugin-transform-class-static-block" "^7.26.0" - "@babel/plugin-transform-classes" "^7.25.9" - "@babel/plugin-transform-computed-properties" "^7.25.9" - "@babel/plugin-transform-destructuring" "^7.25.9" - "@babel/plugin-transform-dotall-regex" "^7.25.9" - "@babel/plugin-transform-duplicate-keys" "^7.25.9" - "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.25.9" - "@babel/plugin-transform-dynamic-import" "^7.25.9" - "@babel/plugin-transform-exponentiation-operator" "^7.26.3" - "@babel/plugin-transform-export-namespace-from" "^7.25.9" - "@babel/plugin-transform-for-of" "^7.26.9" - "@babel/plugin-transform-function-name" "^7.25.9" - "@babel/plugin-transform-json-strings" "^7.25.9" - "@babel/plugin-transform-literals" "^7.25.9" - "@babel/plugin-transform-logical-assignment-operators" "^7.25.9" - "@babel/plugin-transform-member-expression-literals" "^7.25.9" - "@babel/plugin-transform-modules-amd" "^7.25.9" - "@babel/plugin-transform-modules-commonjs" "^7.26.3" - "@babel/plugin-transform-modules-systemjs" "^7.25.9" - "@babel/plugin-transform-modules-umd" "^7.25.9" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.25.9" - "@babel/plugin-transform-new-target" "^7.25.9" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.26.6" - "@babel/plugin-transform-numeric-separator" "^7.25.9" - "@babel/plugin-transform-object-rest-spread" "^7.25.9" - "@babel/plugin-transform-object-super" "^7.25.9" - "@babel/plugin-transform-optional-catch-binding" "^7.25.9" - "@babel/plugin-transform-optional-chaining" "^7.25.9" - "@babel/plugin-transform-parameters" "^7.25.9" - "@babel/plugin-transform-private-methods" "^7.25.9" - "@babel/plugin-transform-private-property-in-object" "^7.25.9" - "@babel/plugin-transform-property-literals" "^7.25.9" - "@babel/plugin-transform-regenerator" "^7.25.9" - "@babel/plugin-transform-regexp-modifiers" "^7.26.0" - "@babel/plugin-transform-reserved-words" "^7.25.9" - "@babel/plugin-transform-shorthand-properties" "^7.25.9" - "@babel/plugin-transform-spread" "^7.25.9" - "@babel/plugin-transform-sticky-regex" "^7.25.9" - "@babel/plugin-transform-template-literals" "^7.26.8" - "@babel/plugin-transform-typeof-symbol" "^7.26.7" - "@babel/plugin-transform-unicode-escapes" "^7.25.9" - "@babel/plugin-transform-unicode-property-regex" "^7.25.9" - "@babel/plugin-transform-unicode-regex" "^7.25.9" - "@babel/plugin-transform-unicode-sets-regex" "^7.25.9" + "@babel/plugin-transform-arrow-functions" "^7.27.1" + "@babel/plugin-transform-async-generator-functions" "^7.28.0" + "@babel/plugin-transform-async-to-generator" "^7.27.1" + "@babel/plugin-transform-block-scoped-functions" "^7.27.1" + "@babel/plugin-transform-block-scoping" "^7.28.0" + "@babel/plugin-transform-class-properties" "^7.27.1" + "@babel/plugin-transform-class-static-block" "^7.27.1" + "@babel/plugin-transform-classes" "^7.28.0" + "@babel/plugin-transform-computed-properties" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.28.0" + "@babel/plugin-transform-dotall-regex" "^7.27.1" + "@babel/plugin-transform-duplicate-keys" "^7.27.1" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-dynamic-import" "^7.27.1" + "@babel/plugin-transform-explicit-resource-management" "^7.28.0" + "@babel/plugin-transform-exponentiation-operator" "^7.27.1" + "@babel/plugin-transform-export-namespace-from" "^7.27.1" + "@babel/plugin-transform-for-of" "^7.27.1" + "@babel/plugin-transform-function-name" "^7.27.1" + "@babel/plugin-transform-json-strings" "^7.27.1" + "@babel/plugin-transform-literals" "^7.27.1" + "@babel/plugin-transform-logical-assignment-operators" "^7.27.1" + "@babel/plugin-transform-member-expression-literals" "^7.27.1" + "@babel/plugin-transform-modules-amd" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-modules-systemjs" "^7.27.1" + "@babel/plugin-transform-modules-umd" "^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-new-target" "^7.27.1" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.27.1" + "@babel/plugin-transform-numeric-separator" "^7.27.1" + "@babel/plugin-transform-object-rest-spread" "^7.28.0" + "@babel/plugin-transform-object-super" "^7.27.1" + "@babel/plugin-transform-optional-catch-binding" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" + "@babel/plugin-transform-parameters" "^7.27.7" + "@babel/plugin-transform-private-methods" "^7.27.1" + "@babel/plugin-transform-private-property-in-object" "^7.27.1" + "@babel/plugin-transform-property-literals" "^7.27.1" + "@babel/plugin-transform-regenerator" "^7.28.0" + "@babel/plugin-transform-regexp-modifiers" "^7.27.1" + "@babel/plugin-transform-reserved-words" "^7.27.1" + "@babel/plugin-transform-shorthand-properties" "^7.27.1" + "@babel/plugin-transform-spread" "^7.27.1" + "@babel/plugin-transform-sticky-regex" "^7.27.1" + "@babel/plugin-transform-template-literals" "^7.27.1" + "@babel/plugin-transform-typeof-symbol" "^7.27.1" + "@babel/plugin-transform-unicode-escapes" "^7.27.1" + "@babel/plugin-transform-unicode-property-regex" "^7.27.1" + "@babel/plugin-transform-unicode-regex" "^7.27.1" + "@babel/plugin-transform-unicode-sets-regex" "^7.27.1" "@babel/preset-modules" "0.1.6-no-external-plugins" - babel-plugin-polyfill-corejs2 "^0.4.10" - babel-plugin-polyfill-corejs3 "^0.11.0" - babel-plugin-polyfill-regenerator "^0.6.1" - core-js-compat "^3.40.0" + babel-plugin-polyfill-corejs2 "^0.4.14" + babel-plugin-polyfill-corejs3 "^0.13.0" + babel-plugin-polyfill-regenerator "^0.6.5" + core-js-compat "^3.43.0" semver "^6.3.1" "@babel/preset-modules@0.1.6-no-external-plugins": @@ -1037,64 +1053,62 @@ esutils "^2.0.2" "@babel/preset-react@^7.0.0", "@babel/preset-react@^7.12.5", "@babel/preset-react@^7.16.0": - version "7.26.3" - resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.26.3.tgz#7c5e028d623b4683c1f83a0bd4713b9100560caa" - integrity sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw== + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.27.1.tgz#86ea0a5ca3984663f744be2fd26cb6747c3fd0ec" + integrity sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA== dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-validator-option" "^7.25.9" - "@babel/plugin-transform-react-display-name" "^7.25.9" - "@babel/plugin-transform-react-jsx" "^7.25.9" - "@babel/plugin-transform-react-jsx-development" "^7.25.9" - "@babel/plugin-transform-react-pure-annotations" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-transform-react-display-name" "^7.27.1" + "@babel/plugin-transform-react-jsx" "^7.27.1" + "@babel/plugin-transform-react-jsx-development" "^7.27.1" + "@babel/plugin-transform-react-pure-annotations" "^7.27.1" "@babel/preset-typescript@^7.16.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz#4a570f1b8d104a242d923957ffa1eaff142a106d" - integrity sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-validator-option" "^7.25.9" - "@babel/plugin-syntax-jsx" "^7.25.9" - "@babel/plugin-transform-modules-commonjs" "^7.25.9" - "@babel/plugin-transform-typescript" "^7.25.9" - -"@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" - integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== - dependencies: - regenerator-runtime "^0.14.0" - -"@babel/template@^7.25.9", "@babel/template@^7.26.9", "@babel/template@^7.3.3": - version "7.26.9" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.26.9.tgz#4577ad3ddf43d194528cff4e1fa6b232fa609bb2" - integrity sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/parser" "^7.26.9" - "@babel/types" "^7.26.9" - -"@babel/traverse@^7.25.9", "@babel/traverse@^7.26.10", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9", "@babel/traverse@^7.7.2": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.10.tgz#43cca33d76005dbaa93024fae536cc1946a4c380" - integrity sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.26.10" - "@babel/parser" "^7.26.10" - "@babel/template" "^7.26.9" - "@babel/types" "^7.26.10" + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz#190742a6428d282306648a55b0529b561484f912" + integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.27.1" + +"@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== + +"@babel/template@^7.27.1", "@babel/template@^7.27.2", "@babel/template@^7.3.3": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0", "@babel/traverse@^7.7.2": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b" + integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.0" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.0" debug "^4.3.1" - globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.26.10", "@babel/types@^7.26.9", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.10.tgz#396382f6335bd4feb65741eacfc808218f859259" - integrity sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ== +"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6", "@babel/types@^7.28.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.0.tgz#2fd0159a6dc7353933920c43136335a9b264d950" + integrity sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg== dependencies: - "@babel/helper-string-parser" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" "@bcoe/v8-coverage@^0.2.3": version "0.2.3" @@ -1320,15 +1334,145 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@esbuild/aix-ppc64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz#164b19122e2ed54f85469df9dea98ddb01d5e79e" + integrity sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw== + +"@esbuild/android-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz#8f539e7def848f764f6432598e51cc3820fde3a5" + integrity sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA== + +"@esbuild/android-arm@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.6.tgz#4ceb0f40113e9861169be83e2a670c260dd234ff" + integrity sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg== + +"@esbuild/android-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.6.tgz#ad4f280057622c25fe985c08999443a195dc63a8" + integrity sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A== + +"@esbuild/darwin-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz#d1f04027396b3d6afc96bacd0d13167dfd9f01f7" + integrity sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA== + "@esbuild/darwin-arm64@^0.24.2": version "0.24.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== +"@esbuild/darwin-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz#2b4a6cedb799f635758d7832d75b23772c8ef68f" + integrity sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg== + +"@esbuild/freebsd-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz#a26266cc97dd78dc3c3f3d6788b1b83697b1055d" + integrity sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg== + +"@esbuild/freebsd-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz#9feb8e826735c568ebfd94859b22a3fbb6a9bdd2" + integrity sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ== + +"@esbuild/linux-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz#c07cbed8e249f4c28e7f32781d36fc4695293d28" + integrity sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ== + +"@esbuild/linux-arm@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz#d6e2cd8ef3196468065d41f13fa2a61aaa72644a" + integrity sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw== + +"@esbuild/linux-ia32@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz#3e682bd47c4eddcc4b8f1393dfc8222482f17997" + integrity sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw== + +"@esbuild/linux-loong64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz#473f5ea2e52399c08ad4cd6b12e6dbcddd630f05" + integrity sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg== + +"@esbuild/linux-mips64el@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz#9960631c9fd61605b0939c19043acf4ef2b51718" + integrity sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw== + +"@esbuild/linux-ppc64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz#477cbf8bb04aa034b94f362c32c86b5c31db8d3e" + integrity sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw== + +"@esbuild/linux-riscv64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz#bcdb46c8fb8e93aa779e9a0a62cd4ac00dcac626" + integrity sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w== + +"@esbuild/linux-s390x@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz#f412cf5fdf0aea849ff51c73fd817c6c0234d46d" + integrity sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw== + +"@esbuild/linux-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz#d8233c09b5ebc0c855712dc5eeb835a3a3341108" + integrity sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig== + +"@esbuild/netbsd-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz#f51ae8dd1474172e73cf9cbaf8a38d1c72dd8f1a" + integrity sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q== + +"@esbuild/netbsd-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz#a267538602c0e50a858cf41dcfe5d8036f8da8e7" + integrity sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g== + +"@esbuild/openbsd-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz#a51be60c425b85c216479b8c344ad0511635f2d2" + integrity sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg== + +"@esbuild/openbsd-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz#7e4a743c73f75562e29223ba69d0be6c9c9008da" + integrity sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw== + +"@esbuild/openharmony-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz#2087a5028f387879154ebf44bdedfafa17682e5b" + integrity sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA== + +"@esbuild/sunos-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz#56531f861723ea0dc6283a2bb8837304223cb736" + integrity sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA== + +"@esbuild/win32-arm64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz#f4989f033deac6fae323acff58764fa8bc01436e" + integrity sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q== + +"@esbuild/win32-ia32@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz#b260e9df71e3939eb33925076d39f63cec7d1525" + integrity sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ== + +"@esbuild/win32-x64@0.25.6": + version "0.25.6" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz#4276edd5c105bc28b11c6a1f76fb9d29d1bd25c1" + integrity sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA== + "@eslint-community/eslint-utils@^4.2.0": - version "4.5.1" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz#b0fc7e06d0c94f801537fd4237edc2706d3b8e4c" - integrity sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w== + version "4.7.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a" + integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== dependencies: eslint-visitor-keys "^3.4.3" @@ -1357,25 +1501,25 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== -"@floating-ui/core@^1.6.0": - version "1.6.9" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6" - integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw== +"@floating-ui/core@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd" + integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw== dependencies: - "@floating-ui/utils" "^0.2.9" + "@floating-ui/utils" "^0.2.10" "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.6.1": - version "1.6.13" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34" - integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w== + version "1.7.2" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45" + integrity sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA== dependencies: - "@floating-ui/core" "^1.6.0" - "@floating-ui/utils" "^0.2.9" + "@floating-ui/core" "^1.7.2" + "@floating-ui/utils" "^0.2.10" -"@floating-ui/utils@^0.2.9": - version "0.2.9" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" - integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== +"@floating-ui/utils@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" + integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== "@formatjs/ecma402-abstract@2.2.4": version "2.2.4" @@ -1461,34 +1605,35 @@ tslib "2" "@hotosm/id@^2.30.2": - version "2.30.2" - resolved "https://registry.yarnpkg.com/@hotosm/id/-/id-2.30.2.tgz#89903f6605897b2e9ba0ce5e2edd64bc6433a195" - integrity sha512-bUpN/Md/fMawOcrgfq70pD9K/VHLBULDQ+1LXjez8KpmzpdZAYa5y3BDl6sCqR1Sn5yyCaim7A4Rw/lWdkdEbg== + version "2.34.0" + resolved "https://registry.yarnpkg.com/@hotosm/id/-/id-2.34.0.tgz#a42277fb57554d81bb5c9c282aa7b8db6cf2878e" + integrity sha512-09XNkKxIPZquSnBjhXuvB+OOL+JBtwMFDI3udjw/9sPyXR4Yq/HTfmfBbgS/11WhsuMDYNNyvzBTzSU67UVNSQ== dependencies: "@mapbox/geojson-area" "^0.2.2" "@mapbox/sexagesimal" "1.2.0" "@mapbox/vector-tile" "^2.0.3" - "@rapideditor/country-coder" "~5.3.0" - "@rapideditor/location-conflation" "~1.4.0" - "@tmcw/togeojson" "^5.8.1" - "@turf/bbox" "^7.1.0" - "@turf/bbox-clip" "^7.1.0" - abortcontroller-polyfill "^1.7.5" + "@rapideditor/country-coder" "~5.4.0" + "@rapideditor/location-conflation" "~1.5.0" + "@tmcw/togeojson" "^7.1.1" + "@turf/bbox" "^7.2.0" + "@turf/bbox-clip" "^7.2.0" + abortcontroller-polyfill "^1.7.8" aes-js "^3.1.2" alif-toolkit "^1.3.0" - core-js-bundle "^3.38.0" + core-js-bundle "^3.42.0" diacritics "1.3.0" exifr "^7.1.3" fast-deep-equal "~3.1.1" fast-json-stable-stringify "2.1.0" lodash-es "~4.17.15" - marked "~14.0.0" + marked "~15.0.11" node-diff3 "~3.1.0" - osm-auth "~2.5.0" + osm-auth "~2.6.0" pannellum "2.5.6" pbf "^4.0.1" polygon-clipping "~0.15.7" - rbush "4.0.0" + rbush "4.0.1" + vitest "^3.1.3" whatwg-fetch "^3.6.20" which-polygon "2.2.1" @@ -1499,17 +1644,6 @@ dependencies: prettier "^2.0.5" -"@hotosm/underpass-ui@^0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@hotosm/underpass-ui/-/underpass-ui-0.0.4.tgz#3b44f83e7737d550074e9842e8c8873a7e9af01c" - integrity sha512-X8sZgfpWKiuGTq3gNeBgLRAH3z33T906Cvgd2fOSfc/MACdXX6Qd8tJ1byX3qkgmd6J0EJsmbZ/KIC/X7oIwCQ== - dependencies: - autoprefixer "^9" - maplibre-gl "^3.3.1" - postcss "^7" - react-timeago "^7.1.0" - tailwindcss "npm:@tailwindcss/postcss7-compat" - "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -1615,6 +1749,11 @@ slash "^3.0.0" strip-ansi "^6.0.0" +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + "@jest/environment@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-27.5.1.tgz#d7425820511fe7158abbecc010140c3fd3be9c74" @@ -1625,12 +1764,12 @@ "@types/node" "*" jest-mock "^27.5.1" -"@jest/expect-utils@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" - integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== +"@jest/expect-utils@30.0.4": + version "30.0.4" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.0.4.tgz#0512fb2588c7fc463ce26fb38c0d47814266d965" + integrity sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA== dependencies: - jest-get-type "^29.6.3" + "@jest/get-type" "30.0.1" "@jest/fake-timers@^27.5.1": version "27.5.1" @@ -1644,6 +1783,11 @@ jest-mock "^27.5.1" jest-util "^27.5.1" +"@jest/get-type@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.0.1.tgz#0d32f1bbfba511948ad247ab01b9007724fc9f52" + integrity sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw== + "@jest/globals@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-27.5.1.tgz#7ac06ce57ab966566c7963431cef458434601b2b" @@ -1653,6 +1797,14 @@ "@jest/types" "^27.5.1" expect "^27.5.1" +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== + dependencies: + "@types/node" "*" + jest-regex-util "30.0.1" + "@jest/reporters@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-27.5.1.tgz#ceda7be96170b03c923c37987b64015812ffec04" @@ -1684,6 +1836,13 @@ terminal-link "^2.0.0" v8-to-istanbul "^8.1.0" +"@jest/schemas@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.1.tgz#27c00d707d480ece0c19126af97081a1af3bc46e" + integrity sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w== + dependencies: + "@sinclair/typebox" "^0.34.0" + "@jest/schemas@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-28.1.3.tgz#ad8b86a66f11f33619e3d7e1dcddd7f2d40ff905" @@ -1691,13 +1850,6 @@ dependencies: "@sinclair/typebox" "^0.24.1" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -1758,6 +1910,19 @@ source-map "^0.6.1" write-file-atomic "^3.0.0" +"@jest/types@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.0.1.tgz#a46df6a99a416fa685740ac4264b9f9cd7da1598" + integrity sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.1" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + "@jest/types@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" @@ -1781,25 +1946,12 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" -"@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== - dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": - version "0.3.8" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" - integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.12" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz#2234ce26c62889f03db3d7fea43c1932ab3e927b" + integrity sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg== dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": @@ -1807,23 +1959,18 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - "@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== + version "0.3.10" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.10.tgz#a35714446a2e84503ff9bfe66f1d1d4846f2075b" + integrity sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q== dependencies: "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz#7358043433b2e5da569aa02cbc4c121da3af27d7" + integrity sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw== "@jridgewell/trace-mapping@0.3.9": version "0.3.9" @@ -1833,10 +1980,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.29" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz#a58d31eaadaf92c6695680b2e1d464a9b8fbf7fc" + integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -2001,12 +2148,12 @@ "@mapbox/point-geometry" "~0.1.0" "@mapbox/vector-tile@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@mapbox/vector-tile/-/vector-tile-2.0.3.tgz#2324c9b9acec7ff70d93d4a7b82e1bc7e4e52b64" - integrity sha512-Fq8PzBAaBeG3sEZA7Bomlv+8ZJcS5KCD6MRlCqiFrroOLnwZFFSJVydk1J9sneScJq9q4yyGfxKa+i7x2TLG8A== + version "2.0.4" + resolved "https://registry.yarnpkg.com/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz#59c5ca80a84c210e61226367b0f9c8fd1737a437" + integrity sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg== dependencies: "@mapbox/point-geometry" "~1.1.0" - "@types/geojson" "^7946.0.14" + "@types/geojson" "^7946.0.16" pbf "^4.0.1" "@mapbox/whoots-js@^3.1.0": @@ -2082,9 +2229,9 @@ integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== "@openhistoricalmap/id@^2.29.2": - version "2.29.2" - resolved "https://registry.yarnpkg.com/@openhistoricalmap/id/-/id-2.29.2.tgz#301ee7b30cedc44a83ba3b9e5ed82ff0118017f9" - integrity sha512-Ltlu8Mun2pIsOZU0ncLar46iNvySj1NpSVYk/OE9h2FeHiXQ6QZdBNGaqoJMU5hVjP2zeGBEobIuxCa9W+ZYlA== + version "2.29.3" + resolved "https://registry.yarnpkg.com/@openhistoricalmap/id/-/id-2.29.3.tgz#d0dd4ccf248571ad7c9b2d6b6c1d5cdcc0d544e4" + integrity sha512-vVjbKglSKjfyWXOPH7boHdOaqcSY+eS3DeJAbZV7I2cvjRMv52mHTPFCzjsPigUc/QhOHZ5YH15rNasIl+6itQ== dependencies: "@mapbox/geojson-area" "^0.2.2" "@mapbox/sexagesimal" "1.2.0" @@ -2224,9 +2371,9 @@ integrity sha512-GbLO7fcSQvrcL+jBsP5yrphRxSrgVWArxL3OyfbU/S+Ghls7ty+94DYdV+AicZL/2VIalcinOIK8OGEGq37fiA== "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": - version "0.5.15" - resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz#f126be97c30b83ed777e2aeabd518bc592e6e7c4" - integrity sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ== + version "0.5.17" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz#8c2f34ca8651df74895422046e11ce5a120e7930" + integrity sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ== dependencies: ansi-html "^0.0.9" core-js-pure "^3.23.3" @@ -2257,10 +2404,10 @@ aes-js "^3.1.2" diacritics "^1.3.0" -"@rapideditor/country-coder@^5.2.1", "@rapideditor/country-coder@^5.3.1", "@rapideditor/country-coder@~5.3.0", "@rapideditor/country-coder@~5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@rapideditor/country-coder/-/country-coder-5.3.1.tgz#4180f6484e0ffbdafcb9c532efca9eae95216531" - integrity sha512-cP4SZVNuLW1K6b6t8n2VsawHGFfwzbpy7Ac5wcIbI3QCMPHFfL7OMs5W41mvKS+sUJnEjTZdloLxMheye6VCIQ== +"@rapideditor/country-coder@^5.2.1", "@rapideditor/country-coder@^5.3.1", "@rapideditor/country-coder@^5.4.0", "@rapideditor/country-coder@~5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@rapideditor/country-coder/-/country-coder-5.4.0.tgz#ce6f02feb84390da839400d5e8f2777152bf3b4a" + integrity sha512-5Kjy2hnDcJZnPpRXMrTNY+jTkwhenaniCD4K6oLdZHYnY0GSM8gIIkOmoB3UikVVcot5vhz6i0QVqbTSyxAvrQ== dependencies: which-polygon "^2.2.1" @@ -2271,6 +2418,13 @@ dependencies: which-polygon "^2.2.1" +"@rapideditor/country-coder@~5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@rapideditor/country-coder/-/country-coder-5.3.1.tgz#4180f6484e0ffbdafcb9c532efca9eae95216531" + integrity sha512-cP4SZVNuLW1K6b6t8n2VsawHGFfwzbpy7Ac5wcIbI3QCMPHFfL7OMs5W41mvKS+sUJnEjTZdloLxMheye6VCIQ== + dependencies: + which-polygon "^2.2.1" + "@rapideditor/location-conflation@~1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@rapideditor/location-conflation/-/location-conflation-1.3.0.tgz#39b0e938def41ee5a6d4bb1479d5082ce1ced8ad" @@ -2283,7 +2437,7 @@ geojson-precision "^1.0.0" polyclip-ts "~0.16.3" -"@rapideditor/location-conflation@~1.4.0", "@rapideditor/location-conflation@~1.4.1": +"@rapideditor/location-conflation@~1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@rapideditor/location-conflation/-/location-conflation-1.4.1.tgz#2daa8c9650e734e500fc7b1eeb3baa30cdfe46ef" integrity sha512-JTtnBGsCGPIlw5Mz8RNAMaAVrbYYOPoreeW3BXYNrV/r/sjOZ9pJiJAibtjYQ3SisezH2QYbd2+4gOQ59wfNlA== @@ -2295,7 +2449,19 @@ geojson-precision "^1.0.0" polyclip-ts "~0.16.7" -"@rapideditor/rapid@^2.4.2": +"@rapideditor/location-conflation@~1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@rapideditor/location-conflation/-/location-conflation-1.5.0.tgz#67d7d46d1ef48ec34429d04e14d9100c5bb66cfb" + integrity sha512-6EsJsXENZgyHyorHrL0fixdC9epFGbiJDsZh/XxnUoEieX8ygiOtWlcBgrIfAOlhatjDBNupiZSCeUhVjsdxbA== + dependencies: + "@aitodotai/json-stringify-pretty-compact" "^1.3.0" + "@mapbox/geojson-area" "^0.2.2" + "@rapideditor/country-coder" "^5.4.0" + circle-to-polygon "^2.2.0" + geojson-precision "^1.0.0" + polyclip-ts "~0.16.8" + +"@rapideditor/rapid@^2.5.2": version "2.5.3" resolved "https://registry.yarnpkg.com/@rapideditor/rapid/-/rapid-2.5.3.tgz#a4cd33f15a6a8efcc3daddda1a1d4a6517bf7dbf" integrity sha512-JWra6TfqsKDBRGPKZ7EvVFuWLg7Imlvs+G0Z7OIvUyNRRPDVI+zaHgbKQTA62v8Q54D4dB4AnJ46S2hFWp7XXg== @@ -2392,15 +2558,115 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@rollup/rollup-android-arm-eabi@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz#6819b7f1e41a49af566f629a1556eaeea774d043" + integrity sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q== + +"@rollup/rollup-android-arm64@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz#7bd5591af68c64a75be1779e2b20f187878daba9" + integrity sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA== + +"@rollup/rollup-darwin-arm64@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz#e216c333e448c67973386e46dbfe8e381aafb055" + integrity sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA== + +"@rollup/rollup-darwin-x64@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz#202f80eea3acfe3f67496fedffa006a5f1ce7f5a" + integrity sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw== + +"@rollup/rollup-freebsd-arm64@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz#4880f9769f1a7eec436b9c146e1d714338c26567" + integrity sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg== + +"@rollup/rollup-freebsd-x64@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz#647d6e333349b1c0fb322c2827ba1a53a0f10301" + integrity sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA== + +"@rollup/rollup-linux-arm-gnueabihf@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz#7ba5c97a7224f49618861d093c4a7b40fa50867b" + integrity sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ== + +"@rollup/rollup-linux-arm-musleabihf@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz#f858dcf498299d6c625ec697a5191e0e41423905" + integrity sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA== + +"@rollup/rollup-linux-arm64-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz#c0f1fc20c50666c61f574536a00cdd486b6aaae1" + integrity sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A== + +"@rollup/rollup-linux-arm64-musl@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz#0214efc3e404ddf108e946ad5f7e4ee2792a155a" + integrity sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A== + +"@rollup/rollup-linux-loongarch64-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz#8303c4ea2ae7bcbb96b2c77cfb53527d964bfceb" + integrity sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g== + +"@rollup/rollup-linux-powerpc64le-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz#4197ffbc61809629094c0fccf825e43a40fbc0ca" + integrity sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw== + +"@rollup/rollup-linux-riscv64-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz#bcb99c9004c9b91e3704a6a70c892cb0599b1f42" + integrity sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg== + +"@rollup/rollup-linux-riscv64-musl@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz#3e943bae9b8b4637c573c1922392beb8a5e81acb" + integrity sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg== + +"@rollup/rollup-linux-s390x-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz#dc43fb467bff9547f5b9937f38668da07fa8fa9f" + integrity sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw== + +"@rollup/rollup-linux-x64-gnu@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz#0699c560fa6ce6b846581a7e6c30c85c22a3f0da" + integrity sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ== + +"@rollup/rollup-linux-x64-musl@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz#9fb1becedcdc9e227d4748576eb8ba2fad8d2e29" + integrity sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg== + +"@rollup/rollup-win32-arm64-msvc@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz#fcf3e62edd76c560252b819f69627685f65887d7" + integrity sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw== + +"@rollup/rollup-win32-ia32-msvc@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz#45a5304491d6da4666f6159be4f739d4d43a283f" + integrity sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q== + +"@rollup/rollup-win32-x64-msvc@4.44.2": + version "4.44.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz#660018c9696ad4f48abe8c5d56db53c81aadba25" + integrity sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== "@rushstack/eslint-patch@^1.1.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz#75dce8e972f90bba488e2b0cc677fb233aa357ab" - integrity sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ== + version "1.12.0" + resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz#326a7b46f6d4cfa54ae25bb888551697873069b4" + integrity sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw== "@sentry-internal/feedback@7.120.3": version "7.120.3" @@ -2444,45 +2710,50 @@ "@sentry/types" "7.120.3" "@sentry/utils" "7.120.3" -"@sentry/cli-darwin@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.42.4.tgz#029521d3052c644e3bac1c926e53d1e658b8cb28" - integrity sha512-PZV4Y97VDWBR4rIt0HkJfXaBXlebIN2s/FDzC3iHINZE5OG62CDFsnC4/lbGlf2/UZLDaGGIK7mYwSHhTvN+HQ== - -"@sentry/cli-linux-arm64@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.42.4.tgz#b5e2d2399764998e3d661f144aae0c3f3495d1f1" - integrity sha512-Ex8vRnryyzC/9e43daEmEqPS+9uirY/l6Hw2lAvhBblFaL7PTWNx52H+8GnYGd9Zy2H3rWNyBDYfHwnErg38zA== - -"@sentry/cli-linux-arm@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.42.4.tgz#286996c3969a553c07a74a2a67c6d3671e2c79b5" - integrity sha512-lBn0oeeg62h68/4Eo6zbPq99Idz5t0VRV48rEU/WKeM4MtQCvG/iGGQ3lBFW2yNiUBzXZIK9poXLEcgbwmcRVw== - -"@sentry/cli-linux-i686@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.42.4.tgz#03e72598dc37e96a99e4329e20db9e74df277f83" - integrity sha512-IBJg0aHjsLCL4LvcFa3cXIjA+4t5kPqBT9y+PoDu4goIFxYD8zl7mbUdGJutvJafTk8Akf4ss4JJXQBjg019zA== - -"@sentry/cli-linux-x64@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.42.4.tgz#a3bc31a909f61029620e5d2ae0f8d8625ed8982a" - integrity sha512-gXI5OEiOSNiAEz7VCE6AZcAgHJ47mlgal3+NmbE8XcHmFOnyDws9FNie6PJAy8KZjXi3nqoBP9JVAbnmOix3uA== - -"@sentry/cli-win32-i686@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.42.4.tgz#0de663fc574f4bce2057e099c5b76b65f99a3bd6" - integrity sha512-vZuR3UPHKqOMniyrijrrsNwn9usaRysXq78F6WV0cL0ZyPLAmY+KBnTDSFk1Oig2pURnzaTm+RtcZu2fc8mlzg== - -"@sentry/cli-win32-x64@2.42.4": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.42.4.tgz#f572a03084f3b1a4f355c1fbeb1339cb56ad91c9" - integrity sha512-OIBj3uaQ6nAERSm5Dcf8UIhyElEEwMNsZEEppQpN4IKl0mrwb/57AznM23Dvpu6GR8WGbVQUSolt879YZR5E9g== +"@sentry/cli-darwin@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.47.0.tgz#171d4ed94a035b35e7cba21e133351fa1d8a5664" + integrity sha512-xEFppdMQogV1A85A/s+Al1VH0NHXk7syy+5BL/jYd168FPeVB3iERP0AwP4h9UhR3/wTe1lTb+tfOKpXrECLCw== + +"@sentry/cli-linux-arm64@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.47.0.tgz#9802fe08164217483926ce8a1becf45f740fea20" + integrity sha512-qjF87W0Vo5vITbm4GXjtX8uQCDRg2gVT0yP1Uz12IuBri80iJj66IANX1wbae2mG2Io1Ibc4AKN5FWd2HpPiKw== + +"@sentry/cli-linux-arm@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.47.0.tgz#32221dcfe46024babf43d846a1e2d05a57b153a8" + integrity sha512-tE8gDcp2qFCTtndz1ViLAo+JNQEEjniBFJAWIFh1utJKwBxBStB0JDporOZHvWUcnSCP5F+W59iuir2YAAQh/w== + +"@sentry/cli-linux-i686@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.47.0.tgz#e551e813e8060de161d6058664e252c952f22291" + integrity sha512-5FYe1dth06xThbr41AOvX67oKZr4xqtDwHJvpFdyCdf+Yh5E5/rtPX35K1beMERgVyT+whRetrNBFAcHnp6LaA== + +"@sentry/cli-linux-x64@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.47.0.tgz#56c257e4df8466709fb80ec21e1b5350ee713464" + integrity sha512-wq67T2UpbTst//1lZGDTeFa7nKsnOpP8rS34TQ3GxsGU1LOjinl9zYl0mUPsoVXIHbWxTHlU6YDNf0q0eB7ddA== + +"@sentry/cli-win32-arm64@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.47.0.tgz#ce7914253f15160373e856e48738932f03adef41" + integrity sha512-a1sv44bMe35V9eW9Zk/kYymXswzJ/RHXNRjkFnW1m1iXx6NauQD3sjEgkryu3UmuvKO9g3pBkMMT1u6xB/08QA== + +"@sentry/cli-win32-i686@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.47.0.tgz#c6414696b7031a590520b01e8a062e0fe71874be" + integrity sha512-QKLSCED00jNHC4cu9GutLWaFAy5vdVGDrIPvVdztSFLS2fRMhRSSPE8tJwlSYh2OfdHhUHQbMOo58cDVfEklBg== + +"@sentry/cli-win32-x64@2.47.0": + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.47.0.tgz#96ceaf75a9ffb39fd255f11fa2b85f4e7e26b591" + integrity sha512-XcM+I7eWpSp8khy44djunVvQKSMsBP698j0swA41Pd1JL0mxLFV/4P9wfWZw1RRB8R71jks74kZvM45AAh2FZw== "@sentry/cli@^2.28.6": - version "2.42.4" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.42.4.tgz#df6ac3e92a60a715b231873433894ed77e601086" - integrity sha512-BoSZDAWJiz/40tu6LuMDkSgwk4xTsq6zwqYoUqLU3vKBR/VsaaQGvu6EWxZXORthfZU2/5Agz0+t220cge6VQw== + version "2.47.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.47.0.tgz#d9a3309d281d03e23651a68e81fbd04c60f52bfe" + integrity sha512-M1zAbc74rGqcWXPi4vowNY7plADjsvKVEZhyUcSq+K3JtZOQ1m1QJgSno31hLbK9V4sx4qyDesNEcBtUjof07w== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -2490,13 +2761,14 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.42.4" - "@sentry/cli-linux-arm" "2.42.4" - "@sentry/cli-linux-arm64" "2.42.4" - "@sentry/cli-linux-i686" "2.42.4" - "@sentry/cli-linux-x64" "2.42.4" - "@sentry/cli-win32-i686" "2.42.4" - "@sentry/cli-win32-x64" "2.42.4" + "@sentry/cli-darwin" "2.47.0" + "@sentry/cli-linux-arm" "2.47.0" + "@sentry/cli-linux-arm64" "2.47.0" + "@sentry/cli-linux-i686" "2.47.0" + "@sentry/cli-linux-x64" "2.47.0" + "@sentry/cli-win32-arm64" "2.47.0" + "@sentry/cli-win32-i686" "2.47.0" + "@sentry/cli-win32-x64" "2.47.0" "@sentry/core@7.120.3": version "7.120.3" @@ -2559,10 +2831,10 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@sinclair/typebox@^0.34.0": + version "0.34.37" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.37.tgz#f331e4db64ff8195e9e3d8449343c85aaa237d6e" + integrity sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw== "@sindresorhus/is@^4.0.0": version "4.6.0" @@ -2704,9 +2976,9 @@ defer-to-connect "^2.0.0" "@tanstack/eslint-plugin-query@^4.29.8": - version "4.38.0" - resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-4.38.0.tgz#a8ffd5b4187ed0b522329a1a950fbc3d467e5167" - integrity sha512-KmcrnjTQzONBqxNWSVKyPNi5tLq0URvIiWThE9HIK5qePGtB0VqoHfOsn4nuGJD268xDNDpFQjQiko9mMa5iLQ== + version "4.39.1" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-4.39.1.tgz#36fd6f1534c3d9c4504e53043669742708804116" + integrity sha512-5YDX4mdRC0hllHKp531CnScFWZU7aFrJ1aTyyuaB6+z0/i0JfcKuckSTYaji3vUk82GALM90eWwHFVRAch+7tQ== "@tanstack/match-sorter-utils@^8.7.0": version "8.19.4" @@ -2715,39 +2987,39 @@ dependencies: remove-accents "0.5.0" -"@tanstack/query-core@4.36.1": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.36.1.tgz#79f8c1a539d47c83104210be2388813a7af2e524" - integrity sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA== +"@tanstack/query-core@4.40.0": + version "4.40.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.40.0.tgz#ce53b25573ca17a2497a8313b1d8f0e135feaaa3" + integrity sha512-7MJTtZkCSuehMC7IxMOCGsLvHS3jHx4WjveSrGsG1Nc1UQLjaFwwkpLA2LmPfvOAxnH4mszMOBFD6LlZE+aB+Q== "@tanstack/react-query-devtools@^4.29.7": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz#7e63601135902a993ca9af73507b125233b1554e" - integrity sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw== + version "4.40.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.40.1.tgz#7e7d7579f0f45463e2ab0d0622e397932e320c63" + integrity sha512-g8g2CCDt91CNhkLsKLVXVBVQSUubExnBdprwwjY5FFM+ZBjv1WfCpGiX1UOezgjVhNxqoi1Is+iMYShdOMoI8Q== dependencies: "@tanstack/match-sorter-utils" "^8.7.0" superjson "^1.10.0" use-sync-external-store "^1.2.0" "@tanstack/react-query@^4.29.7": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.36.1.tgz#acb589fab4085060e2e78013164868c9c785e5d2" - integrity sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw== + version "4.40.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.40.1.tgz#3774e0b73cb089c1bd400a2058bd619814ad297e" + integrity sha512-mgD07S5N8e5v81CArKDWrHE4LM7HxZ9k/KLeD3+NUD9WimGZgKIqojUZf/rXkfAMYZU9p0Chzj2jOXm7xpgHHQ== dependencies: - "@tanstack/query-core" "4.36.1" + "@tanstack/query-core" "4.40.0" use-sync-external-store "^1.2.0" "@tanstack/react-table@^8.20.1": - version "8.21.2" - resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.2.tgz#6a7fce828b64547e33f4606ada8114db496007cc" - integrity sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg== + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== dependencies: - "@tanstack/table-core" "8.21.2" + "@tanstack/table-core" "8.21.3" -"@tanstack/table-core@8.21.2": - version "8.21.2" - resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.2.tgz#dd57595a1773652bb6fb437e90a5f5386a49fd7e" - integrity sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA== +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== "@testing-library/dom@>=7": version "10.4.0" @@ -2816,6 +3088,11 @@ resolved "https://registry.yarnpkg.com/@tmcw/togeojson/-/togeojson-6.0.1.tgz#db9b0e14c3427e9cd1ee4810ed6848edb8a5444d" integrity sha512-eit0noOFdAz6f26ODt5h3E/7wkmW/Sr8b3cVDPTKwYCXk9JL1lfoRKGw2axFVYJC6s5unVTVCjOe86Vak02Vuw== +"@tmcw/togeojson@^7.1.1": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@tmcw/togeojson/-/togeojson-7.1.2.tgz#2d53ac80986d18fc3bbadec708492319cb7b3cd3" + integrity sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2862,7 +3139,7 @@ "@turf/helpers" "^6.5.0" "@turf/invariant" "^6.5.0" -"@turf/bbox-clip@^7.1.0": +"@turf/bbox-clip@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@turf/bbox-clip/-/bbox-clip-7.2.0.tgz#6774208a2b9b505815d85cfde89d2ad036273e7b" integrity sha512-q6RXTpqeUQAYLAieUL1n3J6ukRGsNVDOqcYtfzaJbPW+0VsAf+1cI16sN700t0sekbeU1DH/RRVAHhpf8+36wA== @@ -2887,7 +3164,7 @@ "@turf/helpers" "^6.5.0" "@turf/meta" "^6.5.0" -"@turf/bbox@^7.1.0": +"@turf/bbox@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.2.0.tgz#9db338d6407380f66a72050657f1998c5c5ccc4a" integrity sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA== @@ -3048,9 +3325,9 @@ "@types/babel__traverse" "*" "@types/babel__generator@*": - version "7.6.8" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" - integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== dependencies: "@babel/types" "^7.0.0" @@ -3063,16 +3340,16 @@ "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": - version "7.20.6" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" - integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== + version "7.20.7" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.7.tgz#968cdc2366ec3da159f61166428ee40f370e56c2" + integrity sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng== dependencies: "@babel/types" "^7.20.7" "@types/body-parser@*": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" - integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + version "1.19.6" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474" + integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== dependencies: "@types/connect" "*" "@types/node" "*" @@ -3094,10 +3371,10 @@ "@types/node" "*" "@types/responselike" "^1.0.0" -"@types/chai@^5.0.1": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.0.tgz#fe62a18d33001800d3590792ceb6126142f814a4" - integrity sha512-FWnQYdrG9FAC8KgPVhDFfrPL1FBsL3NtIt2WsxKvwu/61K6HiuDF3xAb7c7w/k9ML2QOUHcwTgU7dKLFPK6sBg== +"@types/chai@^5.0.1", "@types/chai@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" + integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== dependencies: "@types/deep-eql" "*" @@ -3172,10 +3449,10 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.6": - version "1.0.6" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" - integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/estree@0.0.39": version "0.0.39" @@ -3183,9 +3460,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": - version "5.0.6" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz#41fec4ea20e9c7b22f024ab88a95c6bb288f51b8" - integrity sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA== + version "5.0.7" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz#2fa94879c9d46b11a5df4c74ac75befd6b283de6" + integrity sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ== dependencies: "@types/node" "*" "@types/qs" "*" @@ -3203,25 +3480,25 @@ "@types/send" "*" "@types/express@*": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.1.tgz#138d741c6e5db8cc273bec5285cd6e9d0779fc9f" - integrity sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ== + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956" + integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^5.0.0" "@types/serve-static" "*" "@types/express@^4.17.13": - version "4.17.21" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" - integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + version "4.17.23" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.23.tgz#35af3193c640bfd4d7fe77191cd0ed411a433bef" + integrity sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33" "@types/qs" "*" "@types/serve-static" "*" -"@types/geojson@*", "@types/geojson@^7946.0", "@types/geojson@^7946.0.10", "@types/geojson@^7946.0.13", "@types/geojson@^7946.0.14": +"@types/geojson@*", "@types/geojson@^7946.0", "@types/geojson@^7946.0.10", "@types/geojson@^7946.0.13", "@types/geojson@^7946.0.16": version "7946.0.16" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== @@ -3264,9 +3541,9 @@ integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== "@types/http-errors@*": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" - integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" + integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== "@types/http-proxy@^1.17.8": version "1.17.16" @@ -3275,7 +3552,7 @@ dependencies: "@types/node" "*" -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -3287,7 +3564,7 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.0", "@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== @@ -3295,19 +3572,19 @@ "@types/istanbul-lib-report" "*" "@types/jest@*": - version "29.5.14" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" - integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== dependencies: - expect "^29.0.0" - pretty-format "^29.0.0" + expect "^30.0.0" + pretty-format "^30.0.0" "@types/js-levenshtein@^1.1.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz#a6fd0bdc8255b274e5438e0bfb25f154492d1106" integrity sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ== -"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -3361,18 +3638,18 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node-forge@^1.3.0": - version "1.3.11" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" - integrity sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ== + version "1.3.12" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.12.tgz#fe51ad39b4de61d5f3e7b99a6b5524a1fbbe376d" + integrity sha512-a0ToKlRVnUw3aXKQq2F+krxZKq7B8LEQijzPn5RdFAMatARD2JX9o8FBpMXOOrjob0uc13aN+V/AXniOXW4d9A== dependencies: "@types/node" "*" "@types/node@*": - version "22.13.11" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.11.tgz#f0ed6b302dcf0f4229d44ea707e77484ad46d234" - integrity sha512-iEUCUJoU0i3VnrCmgoWCXttklWcvoCIx4jzcP22fioIVSdTmjgoEvmAO/QPw6TcS9k5FrNgn4w7q5lGOd1CT5g== + version "24.0.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.11.tgz#ea6d221eecc9f30f6a9af6ae10e2702b3a1b42cf" + integrity sha512-CJV8eqrYnwQJGMrvcRhQmZfpyniDavB+7nAZYJc6w99hFYJyFN3INV1/2W3QfQrqM36WTLrijJ1fxxvGBmCSxA== dependencies: - undici-types "~6.20.0" + undici-types "~7.8.0" "@types/node@^14.14.31": version "14.18.63" @@ -3420,9 +3697,9 @@ integrity sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ== "@types/prop-types@*", "@types/prop-types@^15.0.0": - version "15.7.14" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== "@types/q@^1.5.1": version "1.5.8" @@ -3430,9 +3707,9 @@ integrity sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw== "@types/qs@*": - version "6.9.18" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.18.tgz#877292caa91f7c1b213032b34626505b746624c2" - integrity sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA== + version "6.14.0" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" + integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ== "@types/range-parser@*": version "1.2.7" @@ -3445,9 +3722,9 @@ integrity sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw== "@types/react-dom@^18.0.0": - version "18.3.5" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.5.tgz#45f9f87398c5dcea085b715c58ddcf1faf65f716" - integrity sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q== + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== "@types/react-transition-group@^4.4.0": version "4.4.12" @@ -3455,16 +3732,16 @@ integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== "@types/react@*": - version "19.0.12" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.12.tgz#338b3f7854adbb784be454b3a83053127af96bd3" - integrity sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA== + version "19.1.8" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3" + integrity sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g== dependencies: csstype "^3.0.2" "@types/react@16 || 17 || 18": - version "18.3.19" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.19.tgz#2b6a01315c9b1b644a8799a7d33efb027150240f" - integrity sha512-fcdJqaHOMDbiAwJnXv6XCzX0jDW77yI3tJqYh1Byn8EL5/S628WRx9b/y3DnNe55zTukUQKrfYxiZls2dHcUMw== + version "18.3.23" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.23.tgz#86ae6f6b95a48c418fecdaccc8069e0fbb63696a" + integrity sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -3494,14 +3771,14 @@ integrity sha512-YesPanU1+WCigC/Aj1Mga8UCOjHIfMNHZ3zzDsUY7lI8GlKnh/Kv2QwJOQ+jNQ36Ru7IfzSedlG14hppYaN13A== "@types/semver@^7.3.12": - version "7.5.8" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" - integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== + version "7.7.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e" + integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA== "@types/send@*": - version "0.17.4" - resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" - integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + version "0.17.5" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74" + integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w== dependencies: "@types/mime" "^1" "@types/node" "*" @@ -3514,9 +3791,9 @@ "@types/express" "*" "@types/serve-static@*", "@types/serve-static@^1.13.10": - version "1.15.7" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" - integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + version "1.15.8" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.8.tgz#8180c3fbe4a70e8f00b9f70b9ba7f08f35987877" + integrity sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg== dependencies: "@types/http-errors" "*" "@types/node" "*" @@ -3536,7 +3813,7 @@ dependencies: "@types/node" "*" -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -3581,9 +3858,9 @@ integrity sha512-Y7L/frVydXRd16MevczslJZQu+QWsrqZlj6ytk7mST3xen0fkx7Ollw31By/89A8Wq+nfNWm/IoTR1ac/0fRhA== "@types/ws@^8.5.5": - version "8.18.0" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5" - integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw== + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== dependencies: "@types/node" "*" @@ -3599,7 +3876,7 @@ dependencies: "@types/yargs-parser" "*" -"@types/yargs@^17.0.8": +"@types/yargs@^17.0.33", "@types/yargs@^17.0.8": version "17.0.33" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.33.tgz#8c32303da83eec050a84b3c7ae7b9f922d13e32d" integrity sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA== @@ -3735,6 +4012,67 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" @@ -3857,9 +4195,9 @@ "@xtuc/long" "4.2.2" "@webgpu/types@^0.1.40": - version "0.1.58" - resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.58.tgz#3a11baf273c5587e8343967d5163c659c799033f" - integrity sha512-+8+NBE17zrc1wS4FvZmmuGTpog5C2H6QC46RY2TTWNpnt15Xvp7dZoUHZQXvb8l5ddKwO8l1pDJa6XTnR4Al1Q== + version "0.1.64" + resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.64.tgz#62c5f9d345d4d270fcf3ecbb111c3b98dcc1aca3" + integrity sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A== "@xmldom/xmldom@0.8.3": version "0.8.3" @@ -3899,7 +4237,7 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== -abortcontroller-polyfill@^1.7.5: +abortcontroller-polyfill@^1.7.5, abortcontroller-polyfill@^1.7.8: version "1.7.8" resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.8.tgz#fe8d4370403f02e2aa37e3d2b0b178bae9d83f49" integrity sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ== @@ -3925,16 +4263,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-node@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" - integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== - dependencies: - acorn "^7.0.0" - acorn-walk "^7.0.0" - xtend "^4.0.2" - -acorn-walk@^7.0.0, acorn-walk@^7.1.1: +acorn-walk@^7.1.1: version "7.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== @@ -3946,15 +4275,15 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^7.0.0, acorn@^7.1.1: +acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.11.0, acorn@^8.14.0, acorn@^8.2.4, acorn@^8.4.1, acorn@^8.8.2, acorn@^8.9.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +acorn@^8.11.0, acorn@^8.14.0, acorn@^8.2.4, acorn@^8.4.1, acorn@^8.9.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== address@^1.0.1, address@^1.1.2: version "1.2.2" @@ -4066,7 +4395,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.0.0, ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== @@ -4094,7 +4423,7 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -arg@^5.0.1, arg@^5.0.2: +arg@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== @@ -4148,17 +4477,19 @@ array-flatten@1.1.1: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== -array-includes@^3.1.6, array-includes@^3.1.8: - version "3.1.8" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" - integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== +array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.4" - is-string "^1.0.7" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" array-union@^2.1.0: version "2.1.0" @@ -4177,7 +4508,7 @@ array.prototype.findlast@^1.2.5: es-object-atoms "^1.0.0" es-shim-unscopables "^1.0.2" -array.prototype.findlastindex@^1.2.5: +array.prototype.findlastindex@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== @@ -4190,7 +4521,7 @@ array.prototype.findlastindex@^1.2.5: es-object-atoms "^1.1.1" es-shim-unscopables "^1.1.0" -array.prototype.flat@^1.3.1, array.prototype.flat@^1.3.2: +array.prototype.flat@^1.3.1, array.prototype.flat@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== @@ -4258,6 +4589,11 @@ asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -4305,19 +4641,6 @@ autoprefixer@^10.4.12, autoprefixer@^10.4.13: picocolors "^1.1.1" postcss-value-parser "^4.2.0" -autoprefixer@^9: - version "9.8.8" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.8.tgz#fd4bd4595385fa6f06599de749a4d5f7a474957a" - integrity sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA== - dependencies: - browserslist "^4.12.0" - caniuse-lite "^1.0.30001109" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - picocolors "^0.2.1" - postcss "^7.0.32" - postcss-value-parser "^4.1.0" - available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -4331,9 +4654,9 @@ axe-core@^4.10.0: integrity sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg== axios@^1.6.7: - version "1.8.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447" - integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw== + version "1.10.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.10.0.tgz#af320aee8632eaf2a400b6a1979fa75856f38d54" + integrity sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -4403,29 +4726,29 @@ babel-plugin-named-asset-import@^0.3.8: resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz#6b7fa43c59229685368683c28bc9734f24524cc2" integrity sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q== -babel-plugin-polyfill-corejs2@^0.4.10: - version "0.4.13" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz#7d445f0e0607ebc8fb6b01d7e8fb02069b91dd8b" - integrity sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g== +babel-plugin-polyfill-corejs2@^0.4.14: + version "0.4.14" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz#8101b82b769c568835611542488d463395c2ef8f" + integrity sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg== dependencies: - "@babel/compat-data" "^7.22.6" - "@babel/helper-define-polyfill-provider" "^0.6.4" + "@babel/compat-data" "^7.27.7" + "@babel/helper-define-polyfill-provider" "^0.6.5" semver "^6.3.1" -babel-plugin-polyfill-corejs3@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz#4e4e182f1bb37c7ba62e2af81d8dd09df31344f6" - integrity sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ== +babel-plugin-polyfill-corejs3@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz#bb7f6aeef7addff17f7602a08a6d19a128c30164" + integrity sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A== dependencies: - "@babel/helper-define-polyfill-provider" "^0.6.3" - core-js-compat "^3.40.0" + "@babel/helper-define-polyfill-provider" "^0.6.5" + core-js-compat "^3.43.0" -babel-plugin-polyfill-regenerator@^0.6.1: - version "0.6.4" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz#428c615d3c177292a22b4f93ed99e358d7906a9b" - integrity sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw== +babel-plugin-polyfill-regenerator@^0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz#32752e38ab6f6767b92650347bf26a31b16ae8c5" + integrity sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg== dependencies: - "@babel/helper-define-polyfill-provider" "^0.6.4" + "@babel/helper-define-polyfill-provider" "^0.6.5" babel-plugin-react-intl@^5.1.18: version "5.1.18" @@ -4544,9 +4867,9 @@ big.js@^5.2.2: integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== bignumber.js@^9.1.0: - version "9.1.2" - resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" - integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== + version "9.3.0" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.0.tgz#bdba7e2a4c1a2eba08290e8dcad4f36393c92acd" + integrity sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA== binary-extensions@^2.0.0: version "2.3.0" @@ -4599,17 +4922,17 @@ boolbase@^1.0.0, boolbase@~1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" @@ -4630,15 +4953,15 @@ browser-split@0.0.1: resolved "https://registry.yarnpkg.com/browser-split/-/browser-split-0.0.1.tgz#7b097574f8e3ead606fb4664e64adfdda2981a93" integrity sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow== -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.18.1, browserslist@^4.21.4, browserslist@^4.24.0, browserslist@^4.24.4: - version "4.24.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" - integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== +browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.4, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.25.1: + version "4.25.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.1.tgz#ba9e8e6f298a1d86f829c9b975e07948967bb111" + integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw== dependencies: - caniuse-lite "^1.0.30001688" - electron-to-chromium "^1.5.73" + caniuse-lite "^1.0.30001726" + electron-to-chromium "^1.5.173" node-releases "^2.0.19" - update-browserslist-db "^1.1.1" + update-browserslist-db "^1.1.3" bser@2.1.1: version "2.1.1" @@ -4670,7 +4993,7 @@ builtin-modules@^3.1.0: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== -bytes@3.1.2, bytes@^3.0.0: +bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -4690,6 +5013,11 @@ bytewise@^1.1.0: bytewise-core "^1.2.2" typewise "^1.0.3" +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + cacheable-lookup@^5.0.3: version "5.0.4" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" @@ -4786,10 +5114,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702: - version "1.0.30001706" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001706.tgz#902c3f896f4b2968031c3a546ab2ef8b465a2c8f" - integrity sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001726: + version "1.0.30001727" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz#22e9706422ad37aa50556af8c10e40e2d93a8b85" + integrity sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" @@ -4801,6 +5129,17 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== +chai@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.1.tgz#a9502462bdc79cf90b4a0953537a9908aa638b47" + integrity sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + chalk@^2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -4862,9 +5201,9 @@ chardet@^0.7.0: integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== chart.js@^4.4.1: - version "4.4.8" - resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.8.tgz#54645b638e9d585099bc16b892947b5e6cd2a552" - integrity sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA== + version "4.5.0" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.0.tgz#11a1ef6c4befc514b1b0b613ebac226c4ad2740b" + integrity sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ== dependencies: "@kurkle/color" "^0.3.0" @@ -4873,12 +5212,17 @@ chartjs-adapter-date-fns@^3.0.0: resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz#c25f63c7f317c1f96f9a7c44bd45eeedb8a478e5" integrity sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg== +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + check-types@^11.2.3: version "11.2.3" resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.2.3.tgz#1ffdf68faae4e941fce252840b1787b8edc93b71" integrity sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg== -chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0: +chokidar@^3.4.2, chokidar@^3.5.3, chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -4910,6 +5254,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +ci-info@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.0.tgz#c39b1013f8fdbd28cd78e62318357d02da160cd7" + integrity sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ== + circle-to-polygon@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/circle-to-polygon/-/circle-to-polygon-2.2.0.tgz#ca4aec7b0536510fc99bf3e1dcef1d53a504a04e" @@ -5026,27 +5375,11 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: +color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@^4.0.1: - version "4.2.3" - resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" - integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== - dependencies: - color-convert "^2.0.1" - color-string "^1.9.0" - colord@^2.9.1: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" @@ -5100,7 +5433,7 @@ commander@^4.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^8.0.0, commander@^8.3.0: +commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== @@ -5209,27 +5542,27 @@ copy-anything@^3.0.2: dependencies: is-what "^4.1.8" -core-js-bundle@^3.36.1, core-js-bundle@^3.38.0: - version "3.41.0" - resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.41.0.tgz#a85668a0146838b6fd6b85bd2ea693c59b67d8d1" - integrity sha512-RGgeDFP49V+1zkTfikXHlCKOe0OQFacusCwGCMNNz7GFz5Qyk9mZ3gpAyCvBsBdjyiak90/z+FzvPGkPAUc4cg== +core-js-bundle@^3.36.1, core-js-bundle@^3.42.0: + version "3.44.0" + resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.44.0.tgz#8c870c11b18ee26c4b7b5da61ac7294e7ceeb4b3" + integrity sha512-aUhGkZs48t2w5sZmn63NoE6XEmLXtocoasQCOTrNdsjLN5LMDe9jPZ7vD+bJvKOTNz1ZcVj1X4ZMgeMq/5P2pA== -core-js-compat@^3.40.0: - version "3.41.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.41.0.tgz#4cdfce95f39a8f27759b667cf693d96e5dda3d17" - integrity sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A== +core-js-compat@^3.43.0: + version "3.44.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.44.0.tgz#62b9165b97e4cbdb8bca16b14818e67428b4a0f8" + integrity sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA== dependencies: - browserslist "^4.24.4" + browserslist "^4.25.1" core-js-pure@^3.23.3: - version "3.41.0" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.41.0.tgz#349fecad168d60807a31e83c99d73d786fe80811" - integrity sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q== + version "3.44.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.44.0.tgz#6e9d6c128c8b967c5eac4f181c2b654d85c28090" + integrity sha512-gvMQAGB4dfVUxpYD0k3Fq8J+n5bB6Ytl15lqlZrOIXFzxOhtPaObfkQGHtMRdyjIf7z2IeNULwi1jEwyS+ltKQ== core-js@^3.19.2: - version "3.41.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.41.0.tgz#57714dafb8c751a6095d028a7428f1fb5834a776" - integrity sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA== + version "3.44.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.44.0.tgz#db4fd4fa07933c1d6898c8b112a1119a9336e959" + integrity sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw== core-util-is@~1.0.0: version "1.0.3" @@ -5292,11 +5625,6 @@ css-blank-pseudo@^3.0.3: dependencies: postcss-selector-parser "^6.0.9" -css-color-names@^0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" - integrity sha512-zj5D7X1U2h2zsXOAM8EyUREBnnts6H+Jm+d1M2DbiQQcUtnqgQsMrdo8JW9R80YFUmIdBZeMu5wvYM7hcgWP/Q== - css-declaration-sorter@^6.3.1: version "6.4.1" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz#28beac7c20bad7f1775be3a7129d7eae409a3a71" @@ -5387,20 +5715,15 @@ css-tree@^1.1.2, css-tree@^1.1.3: mdn-data "2.0.14" source-map "^0.6.1" -css-unit-converter@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" - integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== - css-what@^3.2.1: version "3.4.2" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== css-what@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== css.escape@^1.5.1: version "1.5.1" @@ -5804,10 +6127,10 @@ debug@2.6.9, debug@^2.6.0: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: - version "4.4.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" - integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" @@ -5832,14 +6155,14 @@ decamelize@^1.1.0, decamelize@^1.2.0: integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== decimal.js@^10.2.1: - version "10.5.0" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22" - integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw== + version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== decode-named-character-reference@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz#5d6ce68792808901210dac42a8e9853511e2b8bf" - integrity sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w== + version "1.2.0" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed" + integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q== dependencies: character-entities "^2.0.0" @@ -5860,6 +6183,11 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deep-equal@^2.0.5: version "2.2.3" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" @@ -5936,11 +6264,6 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" -defined@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.1.tgz#c0b9db27bfaffd95d6f61399419b893df0f91ebf" - integrity sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q== - delaunator@5: version "5.0.1" resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.1.tgz#39032b08053923e924d6094fe2cde1a99cc51278" @@ -5996,15 +6319,6 @@ detect-port-alt@^1.1.6: address "^1.0.1" debug "^2.6.0" -detective@^5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034" - integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw== - dependencies: - acorn-node "^1.8.2" - defined "^1.0.0" - minimist "^1.2.6" - diacritics@1.3.0, diacritics@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1" @@ -6020,11 +6334,6 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -6152,9 +6461,9 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: domelementtype "^2.2.0" dompurify@^3.0.9: - version "3.2.4" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.4.tgz#af5a5a11407524431456cf18836c55d13441cd8e" - integrity sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg== + version "3.2.6" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad" + integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -6244,7 +6553,7 @@ eastasianwidth@^0.2.0: "editor-layer-index@github:osmlab/editor-layer-index#gh-pages": version "0.0.0" - resolved "https://codeload.github.com/osmlab/editor-layer-index/tar.gz/67e8ac752a8e70db247e143362bbef025ed769c3" + resolved "https://codeload.github.com/osmlab/editor-layer-index/tar.gz/362d8b81cc8449cb91153f87f4500e572f16a763" edtf@^4.7.1: version "4.7.1" @@ -6267,10 +6576,10 @@ ejs@^3.1.5, ejs@^3.1.6: dependencies: jake "^10.8.5" -electron-to-chromium@^1.5.73: - version "1.5.123" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.123.tgz#fae5bdba0ba27045895176327aa79831aba0790c" - integrity sha512-refir3NlutEZqlKaBLK0tzlVLe5P2wDKS7UQt/3SpibizgsRAPOsqQC3ffw1nlv3ze5gjRQZYHoPymgVZkplFA== +electron-to-chromium@^1.5.173: + version "1.5.180" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz#3e4f6e7494d6371e014af176dfdfd43c8a4b56df" + integrity sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA== emittery@^0.10.2: version "0.10.2" @@ -6308,16 +6617,16 @@ encodeurl@~2.0.0: integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== dependencies: once "^1.4.0" enhanced-resolve@^5.17.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + version "5.18.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" + integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -6350,27 +6659,27 @@ error@^4.3.0: string-template "~0.2.0" xtend "~4.0.0" -es-abstract@^1.17.2, es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9: - version "1.23.9" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.9.tgz#5b45994b7de78dada5c1bebf1379646b32b9d606" - integrity sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA== +es-abstract@^1.17.2, es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== dependencies: array-buffer-byte-length "^1.0.2" arraybuffer.prototype.slice "^1.0.4" available-typed-arrays "^1.0.7" call-bind "^1.0.8" - call-bound "^1.0.3" + call-bound "^1.0.4" data-view-buffer "^1.0.2" data-view-byte-length "^1.0.2" data-view-byte-offset "^1.0.1" es-define-property "^1.0.1" es-errors "^1.3.0" - es-object-atoms "^1.0.0" + es-object-atoms "^1.1.1" es-set-tostringtag "^2.1.0" es-to-primitive "^1.3.0" function.prototype.name "^1.1.8" - get-intrinsic "^1.2.7" - get-proto "^1.0.0" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" get-symbol-description "^1.1.0" globalthis "^1.0.4" gopd "^1.2.0" @@ -6382,21 +6691,24 @@ es-abstract@^1.17.2, es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23 is-array-buffer "^3.0.5" is-callable "^1.2.7" is-data-view "^1.0.2" + is-negative-zero "^2.0.3" is-regex "^1.2.1" + is-set "^2.0.3" is-shared-array-buffer "^1.0.4" is-string "^1.1.1" is-typed-array "^1.1.15" - is-weakref "^1.1.0" + is-weakref "^1.1.1" math-intrinsics "^1.1.0" - object-inspect "^1.13.3" + object-inspect "^1.13.4" object-keys "^1.1.1" object.assign "^4.1.7" own-keys "^1.0.1" - regexp.prototype.flags "^1.5.3" + regexp.prototype.flags "^1.5.4" safe-array-concat "^1.1.3" safe-push-apply "^1.0.0" safe-regex-test "^1.1.0" set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" string.prototype.trim "^1.2.10" string.prototype.trimend "^1.0.9" string.prototype.trimstart "^1.0.8" @@ -6405,7 +6717,7 @@ es-abstract@^1.17.2, es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23 typed-array-byte-offset "^1.0.4" typed-array-length "^1.0.7" unbox-primitive "^1.1.0" - which-typed-array "^1.1.18" + which-typed-array "^1.1.19" es-array-method-boxes-properly@^1.0.0: version "1.0.0" @@ -6459,10 +6771,10 @@ es-iterator-helpers@^1.2.1: iterator.prototype "^1.1.4" safe-array-concat "^1.1.3" -es-module-lexer@^1.2.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.6.0.tgz#da49f587fd9e68ee2404fe4e256c0c7d3a81be21" - integrity sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ== +es-module-lexer@^1.2.1, es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -6497,6 +6809,38 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" +esbuild@^0.25.0: + version "0.25.6" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.6.tgz#9b82a3db2fa131aec069ab040fd57ed0a880cdcd" + integrity sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.6" + "@esbuild/android-arm" "0.25.6" + "@esbuild/android-arm64" "0.25.6" + "@esbuild/android-x64" "0.25.6" + "@esbuild/darwin-arm64" "0.25.6" + "@esbuild/darwin-x64" "0.25.6" + "@esbuild/freebsd-arm64" "0.25.6" + "@esbuild/freebsd-x64" "0.25.6" + "@esbuild/linux-arm" "0.25.6" + "@esbuild/linux-arm64" "0.25.6" + "@esbuild/linux-ia32" "0.25.6" + "@esbuild/linux-loong64" "0.25.6" + "@esbuild/linux-mips64el" "0.25.6" + "@esbuild/linux-ppc64" "0.25.6" + "@esbuild/linux-riscv64" "0.25.6" + "@esbuild/linux-s390x" "0.25.6" + "@esbuild/linux-x64" "0.25.6" + "@esbuild/netbsd-arm64" "0.25.6" + "@esbuild/netbsd-x64" "0.25.6" + "@esbuild/openbsd-arm64" "0.25.6" + "@esbuild/openbsd-x64" "0.25.6" + "@esbuild/openharmony-arm64" "0.25.6" + "@esbuild/sunos-x64" "0.25.6" + "@esbuild/win32-arm64" "0.25.6" + "@esbuild/win32-ia32" "0.25.6" + "@esbuild/win32-x64" "0.25.6" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -6579,10 +6923,10 @@ eslint-import-resolver-node@^0.3.9: is-core-module "^2.13.0" resolve "^1.22.4" -eslint-module-utils@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz#fe4cfb948d61f49203d7b08871982b65b9af0b0b" - integrity sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg== +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" @@ -6595,28 +6939,28 @@ eslint-plugin-flowtype@^8.0.3: string-natural-compare "^3.0.1" eslint-plugin-import@^2.25.3: - version "2.31.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" - integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.12.0" + eslint-module-utils "^2.12.1" hasown "^2.0.2" - is-core-module "^2.15.1" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" object.fromentries "^2.0.8" object.groupby "^1.0.3" - object.values "^1.2.0" + object.values "^1.2.1" semver "^6.3.1" - string.prototype.trimend "^1.0.8" + string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" eslint-plugin-jest@^25.3.0: @@ -6653,9 +6997,9 @@ eslint-plugin-react-hooks@^4.3.0: integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== eslint-plugin-react@^7.27.1: - version "7.37.4" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz#1b6c80b6175b6ae4b26055ae4d55d04c414c7181" - integrity sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ== + version "7.37.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz#2975511472bdda1b272b34d779335c9b0e877065" + integrity sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA== dependencies: array-includes "^3.1.8" array.prototype.findlast "^1.2.5" @@ -6667,7 +7011,7 @@ eslint-plugin-react@^7.27.1: hasown "^2.0.2" jsx-ast-utils "^2.4.1 || ^3.0.0" minimatch "^3.1.2" - object.entries "^1.1.8" + object.entries "^1.1.9" object.fromentries "^2.0.8" object.values "^1.2.1" prop-types "^15.8.1" @@ -6812,6 +7156,13 @@ estree-walker@^1.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -6874,6 +7225,11 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + expect@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74" @@ -6884,16 +7240,17 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" -expect@^29.0.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" - integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== +expect@^30.0.0: + version "30.0.4" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.0.4.tgz#23ce0eaa9a1dcd72fcb78a228b9babdbcf9ddeca" + integrity sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ== dependencies: - "@jest/expect-utils" "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + "@jest/expect-utils" "30.0.4" + "@jest/get-type" "30.0.1" + jest-matcher-utils "30.0.4" + jest-message-util "30.0.2" + jest-mock "30.0.2" + jest-util "30.0.2" express@^4.17.3: version "4.21.2" @@ -6966,7 +7323,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3, fast-deep-equal@~3.1.1, fast-dee resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@^3.2.9, fast-glob@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -7013,6 +7370,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fdir@^6.4.4, fdir@^6.4.6: + version "6.4.6" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.6.tgz#2b268c0232697063111bbf3f64810a2a741ba281" + integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w== + fflate@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" @@ -7197,13 +7559,14 @@ form-data@^3.0.0: mime-types "^2.1.35" form-data@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" - integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + version "4.0.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.3.tgz#608b1b3f3e28be0fccf5901fc85fb3641e5cf0ae" + integrity sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" forwarded@0.2.0: @@ -7264,7 +7627,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -7403,7 +7766,7 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob-parent@^6.0.1, glob-parent@^6.0.2: +glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== @@ -7427,7 +7790,7 @@ glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -7463,11 +7826,6 @@ global@^4.3.0: min-document "^2.19.0" process "^0.11.10" -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - globals@^13.19.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" @@ -7533,9 +7891,9 @@ graphemer@^1.4.0: integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== graphql@^16.8.1: - version "16.10.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c" - integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ== + version "16.11.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.11.0.tgz#96d17f66370678027fdf59b2d4c20b4efaa8a633" + integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw== grid-index@^1.1.0: version "1.1.0" @@ -7550,9 +7908,9 @@ gzip-size@^6.0.0: duplexer "^0.1.2" h3-js@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/h3-js/-/h3-js-4.1.0.tgz#f8c4a8ad36612489a954f1a0bb3f4b7657d364e5" - integrity sha512-LQhmMl1dRQQjMXPzJc7MpZ/CqPOWWuAvVEoVJM9n/s7vHypj+c3Pd5rLQCkAsOgAoAYKbNCsYFE++LF7MvSfCQ== + version "4.2.1" + resolved "https://registry.yarnpkg.com/h3-js/-/h3-js-4.2.1.tgz#dbd598c80a0b3aa1070a2c75d745855bf3c8ac4c" + integrity sha512-HYiUrq5qTRFqMuQu3jEHqxXLk1zsSJiby9Lja/k42wHjabZG7tN9rOuzT/PEFf+Wa7rsnHLMHRWIu0mgcJ0ewQ== handle-thing@^2.0.0: version "2.0.1" @@ -7757,11 +8115,6 @@ headers-polyfill@3.2.5: resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.2.5.tgz#6e67d392c9d113d37448fe45014e0afdd168faed" integrity sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA== -hex-color-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" - integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== - hoist-non-react-statics@3, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -7796,16 +8149,6 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" -hsl-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" - integrity sha512-M5ezZw4LzXbBKMruP+BNANf0k+19hDQMgpzBIYnya//Al+fjNct9Wf3b1WedLqdEs2hKBvxq/jh+DsHJLj0F9A== - -hsla-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" - integrity sha512-7Wn5GMLuHBjZCb2bTmnDOycho0p/7UVaAeqXZGbHrBCl6Yd/xDhQJAXe6Ga9AXJH2I5zY1dEdYw2u1UptnSBJA== - html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -7814,9 +8157,9 @@ html-encoding-sniffer@^2.0.1: whatwg-encoding "^1.0.5" html-entities@^2.1.0, html-entities@^2.3.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" - integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== + version "2.6.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8" + integrity sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ== html-escaper@^2.0.0: version "2.0.2" @@ -7836,11 +8179,6 @@ html-minifier-terser@^6.0.2: relateurl "^0.2.7" terser "^5.10.0" -html-tags@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" - integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== - html-void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" @@ -7878,9 +8216,9 @@ htmlparser2@^6.1.0: entities "^2.0.0" http-cache-semantics@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + version "4.2.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" + integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== http-deceiver@^1.2.7: version "1.2.7" @@ -7909,9 +8247,9 @@ http-errors@~1.6.2: statuses ">= 1.4.0 < 2" http-parser-js@>=0.5.1: - version "0.5.9" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.9.tgz#b817b3ca0edea6236225000d795378707c169cec" - integrity sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw== + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== http-proxy-agent@^4.0.1: version "4.0.1" @@ -7923,9 +8261,9 @@ http-proxy-agent@^4.0.1: debug "4" http-proxy-middleware@^2.0.3: - version "2.0.7" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" - integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== + version "2.0.9" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz#e9e63d68afaa4eee3d147f39149ab84c0c2815ef" + integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" @@ -7964,9 +8302,9 @@ human-signals@^2.1.0: integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== humanize-duration@^3.31.0: - version "3.32.1" - resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.32.1.tgz#922beff5da36fb1cee3de26ada24c592b0fe519b" - integrity sha512-inh5wue5XdfObhu/IGEMiA1nUXigSGcaKNemcbLRKa7jXYGDZXr3LoT9pTIzq2hPEbld7w/qv9h+ikWGz8fL1g== + version "3.33.0" + resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.33.0.tgz#29b3276e68443e513fc85223d094faacdbb8454c" + integrity sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ== iconv-lite@0.4.24, iconv-lite@^0.4.15, iconv-lite@^0.4.24: version "0.4.24" @@ -8020,9 +8358,9 @@ immer@^9.0.7: integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== immutable@^5.0.2: - version "5.0.3" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1" - integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw== + version "5.1.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4" + integrity sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg== import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.3.1" @@ -8180,11 +8518,6 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - is-async-function@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" @@ -8228,19 +8561,7 @@ is-callable@^1.2.7: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-color-stop@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" - integrity sha512-H1U8Vz0cfXNujrJzEcvvwMDW9Ra+biSYA3ThdQvAnMLJkEHQXn6bWzLkxHtVYJ+Sdbx0b6finn3jZiaVe7MAHA== - dependencies: - css-color-names "^0.0.4" - hex-color-regex "^1.1.0" - hsl-regex "^1.0.0" - hsla-regex "^1.0.0" - rgb-regex "^1.0.1" - rgba-regex "^1.0.0" - -is-core-module@^2.13.0, is-core-module@^2.15.1, is-core-module@^2.16.0, is-core-module@^2.5.0: +is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.16.1, is-core-module@^2.5.0: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== @@ -8345,6 +8666,11 @@ is-module@^1.0.0: resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + is-node-process@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" @@ -8481,7 +8807,7 @@ is-weakmap@^2.0.2: resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== -is-weakref@^1.0.2, is-weakref@^1.1.0: +is-weakref@^1.0.2, is-weakref@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== @@ -8701,6 +9027,16 @@ jest-config@^27.5.1: slash "^3.0.0" strip-json-comments "^3.1.1" +jest-diff@30.0.4: + version "30.0.4" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.0.4.tgz#f6e71d19ed6e8f5c7f1bead9ac406c0dd6abce5a" + integrity sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.0.1" + chalk "^4.1.2" + pretty-format "30.0.2" + jest-diff@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" @@ -8711,16 +9047,6 @@ jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -8769,11 +9095,6 @@ jest-get-type@^27.5.1: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== -jest-get-type@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" - integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== - jest-haste-map@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.5.1.tgz#9fd8bd7e7b4fa502d9c6164c5640512b4e811e7f" @@ -8825,6 +9146,16 @@ jest-leak-detector@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" +jest-matcher-utils@30.0.4: + version "30.0.4" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz#1aab71eb7ba401f81d9ef7231feb88392e4a6e54" + integrity sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ== + dependencies: + "@jest/get-type" "30.0.1" + chalk "^4.1.2" + jest-diff "30.0.4" + pretty-format "30.0.2" + jest-matcher-utils@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" @@ -8835,15 +9166,20 @@ jest-matcher-utils@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-matcher-utils@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" - integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== +jest-message-util@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.0.2.tgz#9dfdc37570d172f0ffdc42a0318036ff4008837f" + integrity sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw== dependencies: - chalk "^4.0.0" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.0.1" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.0.2" + slash "^3.0.0" + stack-utils "^2.0.6" jest-message-util@^27.5.1: version "27.5.1" @@ -8875,20 +9211,14 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" -jest-message-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" - integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== +jest-mock@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.0.2.tgz#5e4245f25f6f9532714906cab10a2b9e39eb2183" + integrity sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA== dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.6.3" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.7.0" - slash "^3.0.0" - stack-utils "^2.0.3" + "@jest/types" "30.0.1" + "@types/node" "*" + jest-util "30.0.2" jest-mock@^27.5.1: version "27.5.1" @@ -8903,6 +9233,11 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + jest-regex-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-27.5.1.tgz#4da143f7e9fd1e542d4aa69617b38e4a78365b95" @@ -9029,6 +9364,18 @@ jest-snapshot@^27.5.1: pretty-format "^27.5.1" semver "^7.3.2" +jest-util@30.0.2: + version "30.0.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.0.2.tgz#1bd8411f81e6f5e2ca8b31bb2534ebcd7cbac065" + integrity sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg== + dependencies: + "@jest/types" "30.0.1" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + jest-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-27.5.1.tgz#3ba9771e8e31a0b85da48fe0b0891fb86c01c2f9" @@ -9053,18 +9400,6 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -9168,6 +9503,11 @@ js-levenshtein@^1.1.6: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -9425,7 +9765,7 @@ lie@^3.0.1, lie@~3.3.0: dependencies: immediate "~3.0.5" -lilconfig@^2.0.3, lilconfig@^2.0.5: +lilconfig@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== @@ -9518,11 +9858,6 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== -lodash.topath@^4.5.2: - version "4.5.2" - resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009" - integrity sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg== - lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -9558,6 +9893,11 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +loupe@^3.1.0, loupe@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.4.tgz#784a0060545cb38778ffb19ccde44d7870d5fdd9" + integrity sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg== + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -9606,6 +9946,13 @@ magic-string@^0.25.0, magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.8" +magic-string@^0.30.17: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -9699,7 +10046,7 @@ mapillary-js@^4.1.2: three "^0.134.0" virtual-dom "^2.1.1" -maplibre-gl@^3.3.1, maplibre-gl@^3.6.2: +maplibre-gl@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-3.6.2.tgz#abc2f34bddecabef8c20028eff06d62e36d75ccc" integrity sha512-krg2KFIdOpLPngONDhP6ixCoWl5kbdMINP0moMSJFVX7wX1Clm2M9hlNKXS8vBGlVWwR5R3ZfI6IPrYz7c+aCQ== @@ -9745,15 +10092,10 @@ marked@~12.0.1: resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== -marked@~14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-14.0.0.tgz#79a1477358a59e0660276f8fec76de2c33f35d83" - integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ== - -marked@~15.0.6: - version "15.0.7" - resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.7.tgz#f67d7e34d202ce087e6b879107b5efb04e743314" - integrity sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg== +marked@~15.0.11, marked@~15.0.6: + version "15.0.12" + resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" + integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== martinez-polygon-clipping@^0.7.1: version "0.7.4" @@ -10372,11 +10714,6 @@ mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "^1.2.6" -modern-normalize@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/modern-normalize/-/modern-normalize-1.1.0.tgz#da8e80140d9221426bd4f725c6e11283d34f90b7" - integrity sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA== - moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" @@ -10457,15 +10794,15 @@ mz@^2.7.0: thenify-all "^1.0.0" name-suggestion-index@~6.0.20250126: - version "6.0.20250316" - resolved "https://registry.yarnpkg.com/name-suggestion-index/-/name-suggestion-index-6.0.20250316.tgz#f3eb327004c49265fa3e92e6dbb768a11699f93a" - integrity sha512-wz+nWBB0zcJdz39ce9ee2Z2d+Fn4URcilr37BoxdYumwsoRarS7q5GOvdG6Vww3omAw9w3XbPdNaxT4phDz8oQ== + version "6.0.20250706" + resolved "https://registry.yarnpkg.com/name-suggestion-index/-/name-suggestion-index-6.0.20250706.tgz#d9e69d8e075792ade0dc747ca06bff27e4d19054" + integrity sha512-+p8gCunhj8/IUPN6uuqbVSUxCiayWuFJ6vbjoYZixewC+MedfXBaeHhsxkLa7v8at0UiPjzCFEZcaTiz5Jq3Hw== dependencies: diacritics "^1.3.0" run-s "^0.0.0" which-polygon "^2.2.1" -nanoid@^3.1.31, nanoid@^3.3.8: +nanoid@^3.1.31, nanoid@^3.3.11: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -10533,13 +10870,6 @@ node-diff3@~3.1.0, node-diff3@~3.1.2: resolved "https://registry.yarnpkg.com/node-diff3/-/node-diff3-3.1.2.tgz#49df8d821dc9cbab87bfd6182171d90169613a97" integrity sha512-wUd9TWy059I8mZdH6G3LPNlAEfxDvXtn/RcyFrbqL3v34WlDxn+Mh4HDhOwWuaMk/ROVepe5tTpnGHbve6Db2g== -node-emoji@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" - integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== - dependencies: - lodash "^4.17.21" - node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -10623,32 +10953,22 @@ nth-check@^2.0.0, nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - integrity sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg== - nwsapi@^2.2.0: - version "2.2.19" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.19.tgz#586660f7c24c34691907002309a8dc28064c9c0b" - integrity sha512-94bcyI3RsqiZufXjkr3ltkI86iEl+I7uiHVDtcq9wJUTwYQJ5odHDeSzkkrRzi80jJ8MaeZgqKjH1bAWAFw9bA== + version "2.2.20" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.20.tgz#22e53253c61e7b0e7e93cef42c891154bcca11ef" + integrity sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA== object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-hash@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" - integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== - object-hash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== -object-inspect@^1.13.3: +object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== @@ -10678,7 +10998,7 @@ object.assign@^4.1.4, object.assign@^4.1.7: has-symbols "^1.1.0" object-keys "^1.1.1" -object.entries@^1.1.8: +object.entries@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.9.tgz#e4770a6a1444afb61bd39f984018b5bede25f8b3" integrity sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw== @@ -10720,7 +11040,7 @@ object.groupby@^1.0.3: define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.1.0, object.values@^1.1.6, object.values@^1.2.0, object.values@^1.2.1: +object.values@^1.1.0, object.values@^1.1.6, object.values@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== @@ -10829,7 +11149,7 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== -osm-auth@2.5.0, osm-auth@~2.5.0: +osm-auth@2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/osm-auth/-/osm-auth-2.5.0.tgz#1b439979cfe16a7002e8a06945fbe4b766f76496" integrity sha512-w3NnYbt+0PIih2Kwr1sLfQWehdLbcA3gZNJhX4VOBfeRtvm30iZA3nURphuZDokZ8Kmdv4LWB+AiIng2b+KvIA== @@ -10839,10 +11159,15 @@ osm-auth@~2.4.0: resolved "https://registry.yarnpkg.com/osm-auth/-/osm-auth-2.4.0.tgz#d77a0c17ce985b867902871186b71fd32a21f731" integrity sha512-FvTyYnIl+pjLi9cKJWNM74PjrLUED1f2TnWpAexxJ2qoxr8sdndON/EzXHf0nfMLB07Pn9DPyWVEbTXZ/nID8A== +osm-auth@~2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/osm-auth/-/osm-auth-2.6.0.tgz#d87938ce3f3016364cdfb65fa3ddbd5b8b54a986" + integrity sha512-13B6d6JN4hzvX/yYXGOBXZm9QW7sigYIUEI2sVw6yNlzUe9N91UOh3FOVOOneY5sTEZcuyJMaVCgG0NSjW+6Cg== + osm-community-index@~5.9.1: - version "5.9.1" - resolved "https://registry.yarnpkg.com/osm-community-index/-/osm-community-index-5.9.1.tgz#995a69a73ba740b6af9afbce33e9f5713d491bda" - integrity sha512-ufwDqrANKqQ9/WLs3q8lBTbtZJdrDdPnH/t6IImwq2WQT49xlKLqs2qbY+/8r5uaUERoxBJTWh34n2GUsg8NHg== + version "5.9.2" + resolved "https://registry.yarnpkg.com/osm-community-index/-/osm-community-index-5.9.2.tgz#d2c620f1a295e24dbce1f7a4299bf9ce466e93a6" + integrity sha512-kCiNVej2cYMHTSrUERkkI8FmH+58KtMLGVIkPabnkhBdeFA3hV+u5R7PWiUVnOHubvl3igzNn63Id/+Pj18tBg== dependencies: diacritics "^1.3.0" @@ -11077,6 +11402,16 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + pbf@^3.0.4, pbf@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.3.0.tgz#1790f3d99118333cc7f498de816028a346ef367f" @@ -11112,20 +11447,25 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatc resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== pirates@^4.0.1, pirates@^4.0.4: - version "4.0.6" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" - integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== pixi-filters@^6.1.0: - version "6.1.2" - resolved "https://registry.yarnpkg.com/pixi-filters/-/pixi-filters-6.1.2.tgz#9da0e8ab20ab25377d486ce253b3d1e301cb0c75" - integrity sha512-zGyrMTcfapSIS8We+8RPzxIYkrUhn0KamRFA9qWwxggOk/guJcW05djg8xNvmNsCxn6LzxK5sODeEFIf9SF5Xg== + version "6.1.3" + resolved "https://registry.yarnpkg.com/pixi-filters/-/pixi-filters-6.1.3.tgz#5436f803b821095178a46a2366646e7914d59363" + integrity sha512-bmdI2Ytz+z/NcADkjew2phKq300aQ9p9nVx9OfkMNuoYEl4gW99ZDNQZfsF834V/jj3CKTsIV4jxA+BI45UYOQ== dependencies: "@types/gradient-parser" "^0.1.2" @@ -11346,16 +11686,6 @@ postcss-font-variant@^5.0.0: resolved "https://registry.yarnpkg.com/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz#efd59b4b7ea8bb06127f2d031bfbb7f24d32fa66" integrity sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA== -postcss-functions@^3: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-functions/-/postcss-functions-3.0.0.tgz#0e94d01444700a481de20de4d55fb2640564250e" - integrity sha512-N5yWXWKA+uhpLQ9ZhBRl2bIAdM6oVJYpDojuI1nF2SzXBimJcdjFwiAouBVbO5VuOF3qA6BSFWFc3wXbbj72XQ== - dependencies: - glob "^7.1.2" - object-assign "^4.1.1" - postcss "^6.0.9" - postcss-value-parser "^3.3.0" - postcss-gap-properties@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz#f7e3cddcf73ee19e94ccf7cb77773f9560aa2fff" @@ -11382,14 +11712,6 @@ postcss-initial@^4.0.1: resolved "https://registry.yarnpkg.com/postcss-initial/-/postcss-initial-4.0.1.tgz#529f735f72c5724a0fb30527df6fb7ac54d7de42" integrity sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ== -postcss-js@^2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-2.0.3.tgz#a96f0f23ff3d08cec7dc5b11bf11c5f8077cdab9" - integrity sha512-zS59pAk3deu6dVHyrGqmC3oDXBdNdajk4k1RyxeVXCrcEDBUBHoIhE4QTsmhxgzXxsaqFDAkUZfmMa5f/N/79w== - dependencies: - camelcase-css "^2.0.1" - postcss "^7.0.18" - postcss-js@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" @@ -11405,14 +11727,6 @@ postcss-lab-function@^4.2.1: "@csstools/postcss-progressive-custom-properties" "^1.1.0" postcss-value-parser "^4.2.0" -postcss-load-config@^3.1.0: - version "3.1.4" - resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855" - integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg== - dependencies: - lilconfig "^2.0.5" - yaml "^1.10.2" - postcss-load-config@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" @@ -11518,14 +11832,6 @@ postcss-modules-values@^4.0.0: dependencies: icss-utils "^5.0.0" -postcss-nested@^4: - version "4.2.3" - resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.2.3.tgz#c6f255b0a720549776d220d00c4b70cd244136f6" - integrity sha512-rOv0W1HquRCamWy2kFl3QazJMMe1ku6rCFoAAH+9AcxdbpDeBr6k968MLWuLjvjMcGEip01ak09hKOEgpK9hvw== - dependencies: - postcss "^7.0.32" - postcss-selector-parser "^6.0.2" - postcss-nested@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" @@ -11734,7 +12040,7 @@ postcss-selector-not@^6.0.1: dependencies: postcss-selector-parser "^6.0.10" -postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.6, postcss-selector-parser@^6.0.9, postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: +postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9, postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: version "6.1.2" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== @@ -11765,26 +12071,12 @@ postcss-unique-selectors@^5.1.1: dependencies: postcss-selector-parser "^6.0.5" -postcss-value-parser@^3.3.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" - integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== - postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^6.0.9: - version "6.0.23" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" - integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== - dependencies: - chalk "^2.4.1" - source-map "^0.6.1" - supports-color "^5.4.0" - -postcss@^7, postcss@^7.0.18, postcss@^7.0.32, postcss@^7.0.35: +postcss@^7.0.35: version "7.0.39" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== @@ -11792,12 +12084,12 @@ postcss@^7, postcss@^7.0.18, postcss@^7.0.32, postcss@^7.0.35: picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.3.5, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.47: - version "8.5.3" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== +postcss@^8.3.5, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.47, postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - nanoid "^3.3.8" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -11839,6 +12131,15 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" +pretty-format@30.0.2, pretty-format@^30.0.0: + version "30.0.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.0.2.tgz#54717b6aa2b4357a2e6d83868e10a2ea8dd647c7" + integrity sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg== + dependencies: + "@jest/schemas" "30.0.1" + ansi-styles "^5.2.0" + react-is "^18.3.1" + pretty-format@^27.0.2, pretty-format@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" @@ -11858,20 +12159,6 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^29.0.0, pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - -pretty-hrtime@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" - integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== - process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -11888,12 +12175,12 @@ progress@^2.0.3: integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== proj4@^2.1.4: - version "2.15.0" - resolved "https://registry.yarnpkg.com/proj4/-/proj4-2.15.0.tgz#d77c9956861c8ed516071c4208d576cebe800eb5" - integrity sha512-LqCNEcPdI03BrCHxPLj29vsd5afsm+0sV1H/O3nTDKrv8/LA01ea1z4QADDMjUqxSXWnrmmQDjqFm1J/uZ5RLw== + version "2.19.5" + resolved "https://registry.yarnpkg.com/proj4/-/proj4-2.19.5.tgz#a39572788e653e3f3830d60daa66e0edea75e972" + integrity sha512-hFn7GJwUZ1YiAAfSfur7VRgiH0swIZFxJb7UZ7C4E9tbqyozSn+SI9ZxFgFKmUldtY3tVTBvylJhwfD+O3pHQw== dependencies: mgrs "1.0.0" - wkt-parser "^1.4.0" + wkt-parser "^1.5.1" promise@^8.1.0: version "8.3.0" @@ -11950,9 +12237,9 @@ psl@^1.1.33: punycode "^2.3.1" pump@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8" - integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== + version "3.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d" + integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA== dependencies: end-of-stream "^1.1.0" once "^1.3.1" @@ -11962,16 +12249,6 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -purgecss@^4.0.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/purgecss/-/purgecss-4.1.3.tgz#683f6a133c8c4de7aa82fe2746d1393b214918f7" - integrity sha512-99cKy4s+VZoXnPxaoM23e5ABcP851nC2y2GROkkjS8eJaJtlciGavd7iYAw2V84WeBqggZ12l8ef44G99HmTaw== - dependencies: - commander "^8.0.0" - glob "^7.1.7" - postcss "^8.3.5" - postcss-selector-parser "^6.0.6" - q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -12085,13 +12362,6 @@ rbush@3.0.1, rbush@^3.0.1: dependencies: quickselect "^2.0.0" -rbush@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/rbush/-/rbush-4.0.0.tgz#0536f105aa9d6dc8c0f2be6363a09d474b41cad6" - integrity sha512-F5xw+166FYDZI6jEcz+sWEHL5/J+du3kQWkwqWrPKb6iVoLPZh+2KhTS4OoYqrw1v/RO1xQe6WsLwBvrUAlvXw== - dependencies: - quickselect "^2.0.0" - rbush@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/rbush/-/rbush-4.0.1.tgz#1f55afa64a978f71bf9e9a99bc14ff84f3cb0d6d" @@ -12107,9 +12377,9 @@ rbush@^2.0.1: quickselect "^1.0.1" react-accessible-accordion@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/react-accessible-accordion/-/react-accessible-accordion-5.0.0.tgz#5b61d06aec38906a99f977c10324d9bddec0f64c" - integrity sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ== + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-accessible-accordion/-/react-accessible-accordion-5.0.1.tgz#b406822d28e945bf7c2ffefbcd484bd6a8e679f5" + integrity sha512-EIXkdQs4BObemHtRizVIq4wnbS3z0TPLAmiR2nZSG0Z3Mpm7pr8cwBddK5OcuWniRJ8OpJBqtlpUz0XnQCV06w== react-app-polyfill@^3.0.0: version "3.0.0" @@ -12295,9 +12565,9 @@ react-meta-elements@^1.0.0: integrity sha512-+bSTNyhlr5vpDokclaWMxhcsyS9Og17/kmL0DL3P//VA5a8S3cfYxfOPmEjDlTa04CAwHvgApleIM5ZGWfeSaA== react-onclickoutside@^6.13.0: - version "6.13.1" - resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.1.tgz#1f5e0241c08784b6e65745d91aca0d700c548a89" - integrity sha512-LdrrxK/Yh9zbBQdFbMTXPp3dTSN9B+9YJQucdDu3JNKRrbdU+H+/TVONJoWtOwy4II8Sqf1y/DTI6w/vGPYW0w== + version "6.13.2" + resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz#caa6df2c0cfe017506548fa810303fb85d13fb7c" + integrity sha512-h6Hbf1c8b7tIYY4u90mDdBLY4+AGQVMFtIE89HgC0DtVCh/JfKl477gYqUtGLmjZBKK3MJxomP/lFiLbz4sq9A== "react-placeholder@git+https://github.com/hotosm/react-placeholder.git": version "4.2.0" @@ -12329,17 +12599,17 @@ react-refresh@^0.11.0: integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== react-router-dom@^6.13.0: - version "6.30.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.0.tgz#a64774104508bff56b1affc2796daa3f7e76b7df" - integrity sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA== + version "6.30.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.1.tgz#da2580c272ddb61325e435478566be9563a4a237" + integrity sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw== dependencies: "@remix-run/router" "1.23.0" - react-router "6.30.0" + react-router "6.30.1" -react-router@6.30.0: - version "6.30.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.0.tgz#9789d775e63bc0df60f39ced77c8c41f1e01ff90" - integrity sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ== +react-router@6.30.1: + version "6.30.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.1.tgz#ecb3b883c9ba6dbf5d319ddbc996747f4ab9f4c3" + integrity sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ== dependencies: "@remix-run/router" "1.23.0" @@ -12437,15 +12707,10 @@ react-test-renderer@^18.2.0: react-shallow-renderer "^16.15.0" scheduler "^0.23.2" -react-timeago@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/react-timeago/-/react-timeago-7.2.0.tgz#ae929d7423a63cbc3dc228e49d22fbf586d459ca" - integrity sha512-2KsBEEs+qRhKx/kekUVNSTIpop3Jwd7SRBm0R4Eiq3mPeswRGSsftY9FpKsE/lXLdURyQFiHeHFrIUxLYskG5g== - react-tooltip@^5.26.3: - version "5.28.0" - resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.28.0.tgz#c7b5343ab2d740a428494a3d8315515af1f26f46" - integrity sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg== + version "5.29.1" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.29.1.tgz#ba0b80b04d66822590976d2a32f47544fd9feee2" + integrity sha512-rmJmEb/p99xWhwmVT7F7riLG08wwKykjHiMGbDPloNJk3tdI73oHsVOwzZ4SRjqMdd5/xwb/4nmz0RcoMfY7Bw== dependencies: "@floating-ui/dom" "^1.6.1" classnames "^2.3.0" @@ -12566,14 +12831,6 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -reduce-css-calc@^2.1.8: - version "2.1.8" - resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz#7ef8761a28d614980dc0c982f772c93f7a99de03" - integrity sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg== - dependencies: - css-unit-converter "^1.1.1" - postcss-value-parser "^3.3.0" - redux-persist@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" @@ -12632,24 +12889,12 @@ regenerator-runtime@^0.13.9: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== -regenerator-runtime@^0.14.0: - version "0.14.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" - integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== - -regenerator-transform@^0.15.2: - version "0.15.2" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz#5bbae58b522098ebdf09bca2f83838929001c7a4" - integrity sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg== - dependencies: - "@babel/runtime" "^7.8.4" - regex-parser@^2.2.11: version "2.3.1" resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.3.1.tgz#ee3f70e50bdd81a221d505242cb9a9c275a2ad91" integrity sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ== -regexp.prototype.flags@^1.5.1, regexp.prototype.flags@^1.5.3: +regexp.prototype.flags@^1.5.1, regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== @@ -12897,7 +13142,7 @@ resolve.exports@^1.1.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999" integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ== -resolve@^1.1.7, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.4, resolve@^1.22.8: +resolve@^1.1.7, resolve@^1.10.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.8: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -12950,16 +13195,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rgb-regex@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" - integrity sha512-gDK5mkALDFER2YLqH6imYvK6g02gpNGM4ILDZ472EwWfXZnC2ZEpoB2ECXTyOVUKuk/bPJZMzwQPBYICzP+D3w== - -rgba-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" - integrity sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg== - rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -13001,6 +13236,35 @@ rollup@^2.43.1: optionalDependencies: fsevents "~2.3.2" +rollup@^4.40.0: + version "4.44.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.44.2.tgz#faedb27cb2aa6742530c39668092eecbaf78c488" + integrity sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.44.2" + "@rollup/rollup-android-arm64" "4.44.2" + "@rollup/rollup-darwin-arm64" "4.44.2" + "@rollup/rollup-darwin-x64" "4.44.2" + "@rollup/rollup-freebsd-arm64" "4.44.2" + "@rollup/rollup-freebsd-x64" "4.44.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.44.2" + "@rollup/rollup-linux-arm-musleabihf" "4.44.2" + "@rollup/rollup-linux-arm64-gnu" "4.44.2" + "@rollup/rollup-linux-arm64-musl" "4.44.2" + "@rollup/rollup-linux-loongarch64-gnu" "4.44.2" + "@rollup/rollup-linux-powerpc64le-gnu" "4.44.2" + "@rollup/rollup-linux-riscv64-gnu" "4.44.2" + "@rollup/rollup-linux-riscv64-musl" "4.44.2" + "@rollup/rollup-linux-s390x-gnu" "4.44.2" + "@rollup/rollup-linux-x64-gnu" "4.44.2" + "@rollup/rollup-linux-x64-musl" "4.44.2" + "@rollup/rollup-win32-arm64-msvc" "4.44.2" + "@rollup/rollup-win32-ia32-msvc" "4.44.2" + "@rollup/rollup-win32-x64-msvc" "4.44.2" + fsevents "~2.3.2" + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -13101,9 +13365,9 @@ sass-loader@^12.3.0: neo-async "^2.6.2" sass@^1.71.0: - version "1.86.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114" - integrity sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA== + version "1.89.2" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" + integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA== dependencies: chokidar "^4.0.0" immutable "^5.0.2" @@ -13165,10 +13429,10 @@ schema-utils@^3.0.0: ajv "^6.12.5" ajv-keywords "^3.5.2" -schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.0.tgz#3b669f04f71ff2dfb5aba7ce2d5a9d79b35622c0" - integrity sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g== +schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0, schema-utils@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.2.tgz#0c10878bf4a73fd2b1dfd14b9462b26788c806ae" + integrity sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ== dependencies: "@types/json-schema" "^7.0.9" ajv "^8.9.0" @@ -13206,9 +13470,9 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4: - version "7.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" - integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== send@0.19.0: version "0.19.0" @@ -13352,9 +13616,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.7.3, shell-quote@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.2.tgz#d2d83e057959d53ec261311e9e9b8f51dcb2934a" - integrity sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA== + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== short-number@^1.0.7: version "1.0.7" @@ -13412,6 +13676,11 @@ side-channel@^1.0.4, side-channel@^1.0.6, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -13422,13 +13691,6 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" - integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== - dependencies: - is-arrayish "^0.3.1" - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -13650,13 +13912,18 @@ stable@^0.1.8: resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== -stack-utils@^2.0.3: +stack-utils@^2.0.3, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== dependencies: escape-string-regexp "^2.0.0" +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + stackframe@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" @@ -13679,7 +13946,12 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stop-iteration-iterator@^1.0.0: +std-env@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== + +stop-iteration-iterator@^1.0.0, stop-iteration-iterator@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== @@ -13801,7 +14073,7 @@ string.prototype.trim@^1.2.10: es-object-atoms "^1.0.0" has-property-descriptors "^1.0.2" -string.prototype.trimend@^1.0.8, string.prototype.trimend@^1.0.9: +string.prototype.trimend@^1.0.9: version "1.0.9" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== @@ -13909,6 +14181,13 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-literal@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.0.0.tgz#ce9c452a91a0af2876ed1ae4e583539a353df3fc" + integrity sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA== + dependencies: + js-tokens "^9.0.1" + style-loader@^3.3.1: version "3.3.4" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.4.tgz#f30f786c36db03a45cbd55b6a70d930c479090e7" @@ -13981,7 +14260,7 @@ superjson@^1.10.0: dependencies: copy-anything "^3.0.2" -supports-color@^5.3.0, supports-color@^5.4.0: +supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -14053,9 +14332,9 @@ svgo@^2.7.0: stable "^0.1.8" swiper@^11.1.4: - version "11.2.6" - resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.2.6.tgz#826943bf87158518ca0b1f57d387272b12a07c52" - integrity sha512-8aXpYKtjy3DjcbzZfz+/OX/GhcU5h+looA6PbAzHMZT6ESSycSp9nAjPCenczgJyslV+rUGse64LMGpWE3PX9Q== + version "11.2.10" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.2.10.tgz#ed0b17286b56f7fe8d4b46ed61e6e0bd8daaccad" + integrity sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ== symbol-tree@^3.2.4: version "3.2.4" @@ -14095,56 +14374,15 @@ tailwindcss@^3.0.2: resolve "^1.22.8" sucrase "^3.35.0" -"tailwindcss@npm:@tailwindcss/postcss7-compat": - version "2.2.17" - resolved "https://registry.yarnpkg.com/@tailwindcss/postcss7-compat/-/postcss7-compat-2.2.17.tgz#dc78f3880a2af84163150ff426a39e42b9ae8922" - integrity sha512-3h2svqQAqYHxRZ1KjsJjZOVTQ04m29LjfrLjXyZZEJuvUuJN+BCIF9GI8vhE1s0plS0mogd6E6YLg6mu4Wv/Vw== - dependencies: - arg "^5.0.1" - autoprefixer "^9" - bytes "^3.0.0" - chalk "^4.1.2" - chokidar "^3.5.2" - color "^4.0.1" - cosmiconfig "^7.0.1" - detective "^5.2.0" - didyoumean "^1.2.2" - dlv "^1.1.3" - fast-glob "^3.2.7" - fs-extra "^10.0.0" - glob-parent "^6.0.1" - html-tags "^3.1.0" - is-color-stop "^1.1.0" - is-glob "^4.0.1" - lodash "^4.17.21" - lodash.topath "^4.5.2" - modern-normalize "^1.1.0" - node-emoji "^1.11.0" - normalize-path "^3.0.0" - object-hash "^2.2.0" - postcss "^7" - postcss-functions "^3" - postcss-js "^2" - postcss-load-config "^3.1.0" - postcss-nested "^4" - postcss-selector-parser "^6.0.6" - postcss-value-parser "^4.1.0" - pretty-hrtime "^1.0.3" - purgecss "^4.0.3" - quick-lru "^5.1.1" - reduce-css-calc "^2.1.8" - resolve "^1.20.0" - tmp "^0.2.1" - tapable@^1.0.0: version "1.1.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + version "2.2.2" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.2.tgz#ab4984340d30cb9989a490032f086dbb8b56d872" + integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg== temp-dir@^2.0.0: version "2.0.0" @@ -14189,12 +14427,12 @@ terser-webpack-plugin@^5.2.5, terser-webpack-plugin@^5.3.11: terser "^5.31.1" terser@^5.0.0, terser@^5.10.0, terser@^5.31.1: - version "5.39.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.39.0.tgz#0e82033ed57b3ddf1f96708d123cca717d86ca3a" - integrity sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw== + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== dependencies: "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" + acorn "^8.14.0" commander "^2.20.0" source-map-support "~0.5.20" @@ -14269,6 +14507,29 @@ tiny-osmpbf@^0.1.0: pbf "^3.0.4" tiny-inflate "^1.0.2" +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + tinyqueue@^1.1.0, tinyqueue@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d" @@ -14279,6 +14540,16 @@ tinyqueue@^2.0.3: resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" + integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -14286,11 +14557,6 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" -tmp@^0.2.1: - version "0.2.3" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" - integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -14559,10 +14825,10 @@ underscore@1.12.1: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== -undici-types@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" - integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294" + integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" @@ -14699,7 +14965,7 @@ upath@^1.2.0: resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== -update-browserslist-db@^1.1.1: +update-browserslist-db@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== @@ -14723,9 +14989,9 @@ url-parse@^1.5.3: requires-port "^1.0.0" use-isomorphic-layout-effect@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d" - integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce" + integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA== use-query-params@^2.2.1: version "2.2.1" @@ -14735,9 +15001,9 @@ use-query-params@^2.2.1: serialize-query-params "^2.0.2" use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" - integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== + version "1.5.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" + integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" @@ -14857,6 +15123,60 @@ virtual-dom@^2.1.1: x-is-array "0.1.0" x-is-string "0.1.0" +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.0.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.0.3.tgz#3bf15e1a6d054960406a70fa1052043938b39c5a" + integrity sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.6" + picomatch "^4.0.2" + postcss "^8.5.6" + rollup "^4.40.0" + tinyglobby "^0.2.14" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^3.1.3: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + vt-pbf@^3.1.1, vt-pbf@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.3.tgz#68fd150756465e2edae1cc5c048e063916dcfaac" @@ -14895,9 +15215,9 @@ warning@^4.0.2: loose-envify "^1.0.0" watchpack@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" - integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== + version "2.4.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" + integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -15036,17 +15356,18 @@ webpack-sources@^2.2.0: source-map "^0.6.1" webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== webpack@^5.64.4: - version "5.98.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.98.0.tgz#44ae19a8f2ba97537978246072fb89d10d1fbd17" - integrity sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA== + version "5.99.9" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247" + integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg== dependencies: "@types/eslint-scope" "^3.7.7" "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" "@webassemblyjs/ast" "^1.14.1" "@webassemblyjs/wasm-edit" "^1.14.1" "@webassemblyjs/wasm-parser" "^1.14.1" @@ -15063,7 +15384,7 @@ webpack@^5.64.4: loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^4.3.0" + schema-utils "^4.3.2" tapable "^2.1.1" terser-webpack-plugin "^5.3.11" watchpack "^2.4.1" @@ -15179,7 +15500,7 @@ which-polygon@2.2.1, which-polygon@^2.2.1: lineclip "^1.1.5" rbush "^2.0.1" -which-typed-array@^1.1.13, which-typed-array@^1.1.16, which-typed-array@^1.1.18, which-typed-array@^1.1.2: +which-typed-array@^1.1.13, which-typed-array@^1.1.16, which-typed-array@^1.1.19, which-typed-array@^1.1.2: version "1.1.19" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== @@ -15206,15 +15527,23 @@ which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + wildcard@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== -wkt-parser@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/wkt-parser/-/wkt-parser-1.4.0.tgz#7cca07a6ee5b4baf059b723e62d7fe95bc923bf5" - integrity sha512-qpwO7Ihds/YYDTi1aADFTI1Sm9YC/tTe3SHD24EeIlZxy7Ik6a1b4HOz7jAi0xdUAw487duqpo8OGu+Tf4nwlQ== +wkt-parser@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/wkt-parser/-/wkt-parser-1.5.2.tgz#a8eaf86ac2cc1d0a2e6a8082a930f5c7ebdb5771" + integrity sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q== wkt@^0.1.1: version "0.1.1" @@ -15517,9 +15846,9 @@ ws@^7.4.6: integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== ws@^8.13.0: - version "8.18.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" - integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w== + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== x-is-array@0.1.0: version "0.1.0" @@ -15541,7 +15870,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.0: +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -15567,9 +15896,9 @@ yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== yaml@^2.3.4: - version "2.7.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.0.tgz#aef9bb617a64c937a9a748803786ad8d3ffe1e98" - integrity sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA== + version "2.8.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.0.tgz#15f8c9866211bdc2d3781a0890e44d4fa1a5fff6" + integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ== yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.9" diff --git a/manage.py b/manage.py index acd5cae448..8a2cb4b27d 100644 --- a/manage.py +++ b/manage.py @@ -1,23 +1,22 @@ -import os -import warnings +import atexit import base64 import csv import datetime +import os +import warnings + import click -from flask_migrate import Migrate +from apscheduler.schedulers.background import BackgroundScheduler from dotenv import load_dotenv +from flask_migrate import Migrate +from sqlalchemy import func -from backend import create_app, initialise_counters, db +from backend import create_app, db, initialise_counters +from backend.models.postgis.task import Task, TaskHistory +from backend.services.interests_service import InterestService +from backend.services.stats_service import StatsService from backend.services.users.authentication_service import AuthenticationService from backend.services.users.user_service import UserService -from backend.services.stats_service import StatsService -from backend.services.interests_service import InterestService -from backend.models.postgis.task import Task, TaskHistory - -from sqlalchemy import func -import atexit -from apscheduler.schedulers.background import BackgroundScheduler - # Load configuration from file into environment load_dotenv(os.path.join(os.path.dirname(__file__), "tasking-manager.env")) @@ -137,8 +136,9 @@ def update_project_categories(filename): # This is compatibility code with previous releases # People should generally prefer `flask `. from sys import argv - from flask.cli import FlaskGroup + from click import Command + from flask.cli import FlaskGroup cli = FlaskGroup(create_app=lambda: application) cli.add_command( diff --git a/migrations/alembic.ini b/migrations/alembic.ini index 59e0ae6bf6..99c6cbda81 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -7,11 +7,11 @@ # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false -script_location = /src/migrations +script_location = /usr/src/app/migrations # Custom param that enables us to specify tables to ignore when determining migrations [alembic:exclude] -tables = spatial_ref_sys +tables = spatial_ref_sys,layer,topology index = idx_username_lower # Logging configuration diff --git a/migrations/env.py b/migrations/env.py index 9a62c50755..669c2188b9 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,10 +1,20 @@ -from __future__ import with_statement +import asyncio +import logging +import os +import sys +from logging.config import fileConfig + from alembic import context -from sqlalchemy import engine_from_config, pool +from asyncpg import Connection from geoalchemy2 import alembic_helpers -from logging.config import fileConfig -from flask import current_app -import logging +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from backend.config import settings +from backend.db import Base + +project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(project_dir) # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -15,15 +25,17 @@ fileConfig(config.config_file_name) logger = logging.getLogger("alembic.env") -# add your model's MetaData object here +# add your model's MetaData object here or +# import models to backend models init # for 'autogenerate' support # from myapp import mymodel -# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + + +# target_metadata = current_app.extensions["migrate"].db.metadata +def get_url(): + return settings.SQLALCHEMY_DATABASE_URI.unicode_string() -config.set_main_option( - "sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") -) -target_metadata = current_app.extensions["migrate"].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -59,9 +71,10 @@ def run_migrations_offline(): script output. """ - url = config.get_main_option("sqlalchemy.url") + url = get_url() context.configure( url=url, + target_metadata=target_metadata, include_object=include_object, process_revision_directives=alembic_helpers.writer, render_item=alembic_helpers.render_item, @@ -71,7 +84,18 @@ def run_migrations_offline(): context.run_migrations() -def run_migrations_online(): +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + include_object=include_object, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine @@ -90,30 +114,21 @@ def process_revision_directives(context, revision, directives): directives[:] = [] logger.info("No changes in schema detected.") - engine = engine_from_config( - config.get_section(config.config_ini_section), + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = async_engine_from_config( + configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, ) - connection = engine.connect() - context.configure( - connection=connection, - target_metadata=target_metadata, - process_revision_directives=process_revision_directives, - include_object=include_object, - render_item=alembic_helpers.render_item, - **current_app.extensions["migrate"].configure_args, - ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) - try: - with context.begin_transaction(): - context.run_migrations() - finally: - connection.close() + await connectable.dispose() if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() + asyncio.run(run_migrations_online()) diff --git a/migrations/versions/05e1ecf9953a_.py b/migrations/versions/05e1ecf9953a_.py index 43b2ba923b..5699b1555b 100644 --- a/migrations/versions/05e1ecf9953a_.py +++ b/migrations/versions/05e1ecf9953a_.py @@ -5,9 +5,9 @@ Create Date: 2018-12-04 19:53:41.477085 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "05e1ecf9953a" diff --git a/migrations/versions/05f1b650ddbc_.py b/migrations/versions/05f1b650ddbc_.py index 35fbc14457..5c0ab9fb13 100644 --- a/migrations/versions/05f1b650ddbc_.py +++ b/migrations/versions/05f1b650ddbc_.py @@ -5,9 +5,9 @@ Create Date: 2022-08-17 15:58:37.118728 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "05f1b650ddbc" diff --git a/migrations/versions/068674f06b0f_.py b/migrations/versions/068674f06b0f_.py index e947f948aa..f39b442186 100644 --- a/migrations/versions/068674f06b0f_.py +++ b/migrations/versions/068674f06b0f_.py @@ -5,9 +5,9 @@ Create Date: 2019-10-02 08:45:00.553060 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "068674f06b0f" diff --git a/migrations/versions/0a6b82b55983_.py b/migrations/versions/0a6b82b55983_.py index 1b2a22790d..167127c2b1 100644 --- a/migrations/versions/0a6b82b55983_.py +++ b/migrations/versions/0a6b82b55983_.py @@ -5,11 +5,13 @@ Create Date: 2018-09-04 19:09:45.866336 """ -from alembic import op -import sqlalchemy as sa + import re -from backend.models.postgis.message import MessageType +import sqlalchemy as sa +from alembic import op + +from backend.models.postgis.message import MessageType # revision identifiers, used by Alembic. revision = "0a6b82b55983" diff --git a/migrations/versions/0aaac86a48dc_.py b/migrations/versions/0aaac86a48dc_.py index 7d2e002263..7b2ec3ab62 100644 --- a/migrations/versions/0aaac86a48dc_.py +++ b/migrations/versions/0aaac86a48dc_.py @@ -5,9 +5,9 @@ Create Date: 2017-05-01 14:04:16.188885 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "0aaac86a48dc" diff --git a/migrations/versions/0d47c10c8a66_.py b/migrations/versions/0d47c10c8a66_.py index 13d0d064ee..19bfae3411 100644 --- a/migrations/versions/0d47c10c8a66_.py +++ b/migrations/versions/0d47c10c8a66_.py @@ -5,9 +5,9 @@ Create Date: 2019-01-13 20:51:12.809062 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "0d47c10c8a66" diff --git a/migrations/versions/0eeaa5aed53b_.py b/migrations/versions/0eeaa5aed53b_.py index 1a6b2f27e4..b708b5ed19 100644 --- a/migrations/versions/0eeaa5aed53b_.py +++ b/migrations/versions/0eeaa5aed53b_.py @@ -5,9 +5,9 @@ Create Date: 2019-06-26 17:31:17.238355 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "0eeaa5aed53b" diff --git a/migrations/versions/0eee8c1abd3a_.py b/migrations/versions/0eee8c1abd3a_.py index d71c5c5f12..2d825ede21 100644 --- a/migrations/versions/0eee8c1abd3a_.py +++ b/migrations/versions/0eee8c1abd3a_.py @@ -5,14 +5,15 @@ Create Date: 2019-05-24 23:05:45.512395 """ -from alembic import op -from sqlalchemy.dialects.postgresql import ARRAY -import sqlalchemy as sa + import json -from shapely.geometry import shape -import shapely.wkt import sys +import shapely.wkt +import sqlalchemy as sa +from alembic import op +from shapely.geometry import shape +from sqlalchemy.dialects.postgresql import ARRAY # revision identifiers, used by Alembic. revision = "0eee8c1abd3a" diff --git a/migrations/versions/14340f1e0d6b_.py b/migrations/versions/14340f1e0d6b_.py index 79ebc84ecb..971663addf 100644 --- a/migrations/versions/14340f1e0d6b_.py +++ b/migrations/versions/14340f1e0d6b_.py @@ -5,8 +5,9 @@ Create Date: 2020-03-13 10:35:57.594664 """ -from alembic import op + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "14340f1e0d6b" diff --git a/migrations/versions/14842761654b_.py b/migrations/versions/14842761654b_.py index ef68ce8b31..52537fe353 100644 --- a/migrations/versions/14842761654b_.py +++ b/migrations/versions/14842761654b_.py @@ -5,9 +5,9 @@ Create Date: 2020-11-11 07:18:58.675778 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "14842761654b" diff --git a/migrations/versions/1579d24928e7_.py b/migrations/versions/1579d24928e7_.py index 93551be9e2..1965391172 100644 --- a/migrations/versions/1579d24928e7_.py +++ b/migrations/versions/1579d24928e7_.py @@ -5,9 +5,9 @@ Create Date: 2020-05-29 09:55:16.565573 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "1579d24928e7" diff --git a/migrations/versions/22e7d7e0fa02_.py b/migrations/versions/22e7d7e0fa02_.py index fab4d73ba6..f05007a0a7 100644 --- a/migrations/versions/22e7d7e0fa02_.py +++ b/migrations/versions/22e7d7e0fa02_.py @@ -5,9 +5,9 @@ Create Date: 2018-08-06 15:31:03.973448 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "22e7d7e0fa02" diff --git a/migrations/versions/22e9a0e9b254_.py b/migrations/versions/22e9a0e9b254_.py index 2cf23a8fdc..8e0974cb56 100644 --- a/migrations/versions/22e9a0e9b254_.py +++ b/migrations/versions/22e9a0e9b254_.py @@ -5,8 +5,8 @@ Create Date: 2019-11-21 04:20:16.572298 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "22e9a0e9b254" diff --git a/migrations/versions/251a7638da78_.py b/migrations/versions/251a7638da78_.py index 43845bf951..59661babf8 100644 --- a/migrations/versions/251a7638da78_.py +++ b/migrations/versions/251a7638da78_.py @@ -5,9 +5,9 @@ Create Date: 2018-08-30 07:46:49.074078 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "251a7638da78" diff --git a/migrations/versions/29097876c7e6_.py b/migrations/versions/29097876c7e6_.py index 72a90e54e9..0fe380e646 100644 --- a/migrations/versions/29097876c7e6_.py +++ b/migrations/versions/29097876c7e6_.py @@ -5,8 +5,8 @@ Create Date: 2019-12-04 11:21:42.622908 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "29097876c7e6" diff --git a/migrations/versions/2cfa981c70e7_.py b/migrations/versions/2cfa981c70e7_.py index 17aadb5bca..817c47b8c1 100644 --- a/migrations/versions/2cfa981c70e7_.py +++ b/migrations/versions/2cfa981c70e7_.py @@ -5,8 +5,9 @@ Create Date: 2020-07-16 10:30:59.885956 """ -from alembic import op + import geoalchemy2 +from alembic import op # revision identifiers, used by Alembic. revision = "2cfa981c70e7" diff --git a/migrations/versions/2d6a7ad4e1eb_.py b/migrations/versions/2d6a7ad4e1eb_.py index 95f27466db..24ff3d3186 100644 --- a/migrations/versions/2d6a7ad4e1eb_.py +++ b/migrations/versions/2d6a7ad4e1eb_.py @@ -5,8 +5,8 @@ Create Date: 2020-03-23 12:10:20.120500 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "2d6a7ad4e1eb" diff --git a/migrations/versions/2ee4bca188a9_.py b/migrations/versions/2ee4bca188a9_.py index 96d9a09d83..c0a1cd46b2 100644 --- a/migrations/versions/2ee4bca188a9_.py +++ b/migrations/versions/2ee4bca188a9_.py @@ -5,9 +5,9 @@ Create Date: 2021-03-12 09:42:54.198713 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "2ee4bca188a9" diff --git a/migrations/versions/30b091260689_.py b/migrations/versions/30b091260689_.py index a12bbc13e8..cd31d8dfcc 100644 --- a/migrations/versions/30b091260689_.py +++ b/migrations/versions/30b091260689_.py @@ -5,9 +5,9 @@ Create Date: 2018-07-30 12:52:52.845202 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "30b091260689" diff --git a/migrations/versions/3b8b0956b217_.py b/migrations/versions/3b8b0956b217_.py index 85c20c3ce3..12828d5bda 100644 --- a/migrations/versions/3b8b0956b217_.py +++ b/migrations/versions/3b8b0956b217_.py @@ -5,9 +5,9 @@ Create Date: 2022-07-27 10:30:55.193989 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "3b8b0956b217" diff --git a/migrations/versions/3ee58dee57c9_.py b/migrations/versions/3ee58dee57c9_.py index a27448b354..c1b086e68a 100644 --- a/migrations/versions/3ee58dee57c9_.py +++ b/migrations/versions/3ee58dee57c9_.py @@ -5,9 +5,9 @@ Create Date: 2018-08-24 13:55:32.308278 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "3ee58dee57c9" diff --git a/migrations/versions/42c45e74752b_.py b/migrations/versions/42c45e74752b_.py index 155f8e73b4..c9190e7d1d 100644 --- a/migrations/versions/42c45e74752b_.py +++ b/migrations/versions/42c45e74752b_.py @@ -5,9 +5,9 @@ Create Date: 2023-03-29 09:07:55.771834 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "42c45e74752b" diff --git a/migrations/versions/451f6bd05a19_.py b/migrations/versions/451f6bd05a19_.py index 55cc343a6e..8c7f0d66ee 100644 --- a/migrations/versions/451f6bd05a19_.py +++ b/migrations/versions/451f6bd05a19_.py @@ -5,8 +5,8 @@ Create Date: 2019-07-02 17:03:55.567940 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "451f6bd05a19" @@ -16,9 +16,9 @@ def upgrade(): + op.execute("DROP TRIGGER IF EXISTS tsvectorupdate ON project_info;") op.execute( """ - DROP TRIGGER IF EXISTS tsvectorupdate ON project_info; CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON project_info FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(text_searchable, "pg_catalog.english", project_id_str, short_description, description) """ diff --git a/migrations/versions/48c3aed123cf_.py b/migrations/versions/48c3aed123cf_.py index a28db1486f..2ffe1cc89a 100644 --- a/migrations/versions/48c3aed123cf_.py +++ b/migrations/versions/48c3aed123cf_.py @@ -5,9 +5,9 @@ Create Date: 2017-05-01 12:09:36.887823 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "48c3aed123cf" diff --git a/migrations/versions/4b9fb4d3f349_.py b/migrations/versions/4b9fb4d3f349_.py index 24f54436ad..e40fb24df1 100644 --- a/migrations/versions/4b9fb4d3f349_.py +++ b/migrations/versions/4b9fb4d3f349_.py @@ -5,8 +5,9 @@ Create Date: 2019-07-19 13:38:08.010157 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. diff --git a/migrations/versions/4ed992117718_.py b/migrations/versions/4ed992117718_.py index b4fef308a3..c62deb4954 100644 --- a/migrations/versions/4ed992117718_.py +++ b/migrations/versions/4ed992117718_.py @@ -5,8 +5,10 @@ Create Date: 2022-09-20 13:11:54.721669 """ -from alembic import op + import sqlalchemy as sa +from alembic import op + from backend.models.postgis.statuses import TeamJoinMethod, TeamVisibility # revision identifiers, used by Alembic. diff --git a/migrations/versions/51ccc46f1b8a_.py b/migrations/versions/51ccc46f1b8a_.py index 556660b150..1148d3e84e 100644 --- a/migrations/versions/51ccc46f1b8a_.py +++ b/migrations/versions/51ccc46f1b8a_.py @@ -5,9 +5,9 @@ Create Date: 2019-10-16 19:30:51.064696 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "51ccc46f1b8a" diff --git a/migrations/versions/55790eeec3bf_.py b/migrations/versions/55790eeec3bf_.py index 98b0ab7fc0..974531757f 100644 --- a/migrations/versions/55790eeec3bf_.py +++ b/migrations/versions/55790eeec3bf_.py @@ -5,8 +5,9 @@ Create Date: 2020-05-12 18:43:37.666725 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. diff --git a/migrations/versions/567b75e74955_.py b/migrations/versions/567b75e74955_.py index 17a68f5148..63a901916e 100644 --- a/migrations/versions/567b75e74955_.py +++ b/migrations/versions/567b75e74955_.py @@ -5,9 +5,9 @@ Create Date: 2017-06-02 17:18:52.374843 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "567b75e74955" diff --git a/migrations/versions/5952780a577e_.py b/migrations/versions/5952780a577e_.py index 4fd3a892ef..94ea305fb3 100644 --- a/migrations/versions/5952780a577e_.py +++ b/migrations/versions/5952780a577e_.py @@ -5,9 +5,9 @@ Create Date: 2020-06-11 16:58:45.700192 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "5952780a577e" diff --git a/migrations/versions/5eba4824505e_.py b/migrations/versions/5eba4824505e_.py index 4bc9f72c79..0df747709c 100644 --- a/migrations/versions/5eba4824505e_.py +++ b/migrations/versions/5eba4824505e_.py @@ -5,9 +5,9 @@ Create Date: 2019-10-22 14:17:35.925047 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "5eba4824505e" diff --git a/migrations/versions/6276c258149c_.py b/migrations/versions/6276c258149c_.py index 7282ad5213..efd665904c 100644 --- a/migrations/versions/6276c258149c_.py +++ b/migrations/versions/6276c258149c_.py @@ -6,9 +6,8 @@ """ -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision = "6276c258149c" diff --git a/migrations/versions/64b682d53e23_.py b/migrations/versions/64b682d53e23_.py index 8f3d254b4b..d4ca62db30 100644 --- a/migrations/versions/64b682d53e23_.py +++ b/migrations/versions/64b682d53e23_.py @@ -5,9 +5,9 @@ Create Date: 2017-06-06 16:53:47.604019 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "64b682d53e23" diff --git a/migrations/versions/6612e4d6524c_.py b/migrations/versions/6612e4d6524c_.py index 2d8a949a7e..b3f9119972 100644 --- a/migrations/versions/6612e4d6524c_.py +++ b/migrations/versions/6612e4d6524c_.py @@ -5,9 +5,9 @@ Create Date: 2019-08-19 21:40:06.669352 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "6612e4d6524c" diff --git a/migrations/versions/7435b0a865e6_.py b/migrations/versions/7435b0a865e6_.py index f9be5de756..c3192d40c1 100644 --- a/migrations/versions/7435b0a865e6_.py +++ b/migrations/versions/7435b0a865e6_.py @@ -5,9 +5,9 @@ Create Date: 2019-08-20 08:54:12.215666 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "7435b0a865e6" diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index 81bec2b4ee..d6a8e072ed 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -9,8 +9,8 @@ """ -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. diff --git a/migrations/versions/772aff899389_.py b/migrations/versions/772aff899389_.py index 4a9bd27a61..dcb369c559 100644 --- a/migrations/versions/772aff899389_.py +++ b/migrations/versions/772aff899389_.py @@ -5,9 +5,9 @@ Create Date: 2019-11-08 19:09:46.152809 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "772aff899389" diff --git a/migrations/versions/7937dae319b5_.py b/migrations/versions/7937dae319b5_.py index 91704f216a..60f5ad88d5 100644 --- a/migrations/versions/7937dae319b5_.py +++ b/migrations/versions/7937dae319b5_.py @@ -1,14 +1,14 @@ -""" Ensure Project's country names are in English +"""Ensure Project's country names are in English Revision ID: 7937dae319b5 Revises: 14842761654b Create Date: 2020-09-21 17:17:10.542429 """ -from alembic import op -import sqlalchemy as sa -import requests +import requests +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "7937dae319b5" diff --git a/migrations/versions/7991a7e1b88d_.py b/migrations/versions/7991a7e1b88d_.py index c82eb6fe11..5e01852cd0 100644 --- a/migrations/versions/7991a7e1b88d_.py +++ b/migrations/versions/7991a7e1b88d_.py @@ -5,9 +5,9 @@ Create Date: 2017-05-30 17:19:55.061108 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "7991a7e1b88d" diff --git a/migrations/versions/7bbc01082457_.py b/migrations/versions/7bbc01082457_.py index 33db25d568..4ecad17e29 100644 --- a/migrations/versions/7bbc01082457_.py +++ b/migrations/versions/7bbc01082457_.py @@ -5,10 +5,11 @@ Create Date: 2020-01-29 11:19:20.113089 """ -from alembic import op -import sqlalchemy as sa + import sys +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "7bbc01082457" diff --git a/migrations/versions/7d55a089b5bc_.py b/migrations/versions/7d55a089b5bc_.py index f2699e1e2e..90da83c70b 100644 --- a/migrations/versions/7d55a089b5bc_.py +++ b/migrations/versions/7d55a089b5bc_.py @@ -5,8 +5,9 @@ Create Date: 2017-06-26 17:22:12.828934 """ -from alembic import op + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "7d55a089b5bc" diff --git a/migrations/versions/824268a7a675_.py b/migrations/versions/824268a7a675_.py index b20ed30072..20b3d84766 100644 --- a/migrations/versions/824268a7a675_.py +++ b/migrations/versions/824268a7a675_.py @@ -5,8 +5,8 @@ Create Date: 2018-12-07 12:54:19.964101 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "824268a7a675" diff --git a/migrations/versions/83eb4cbb0abd_.py b/migrations/versions/83eb4cbb0abd_.py index 7c6d80e0a3..5ab191ffa2 100644 --- a/migrations/versions/83eb4cbb0abd_.py +++ b/migrations/versions/83eb4cbb0abd_.py @@ -5,9 +5,9 @@ Create Date: 2019-11-07 09:30:29.175751 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "83eb4cbb0abd" diff --git a/migrations/versions/84c793a951b2_.py b/migrations/versions/84c793a951b2_.py index 8ee3aaa577..2133c8fcff 100644 --- a/migrations/versions/84c793a951b2_.py +++ b/migrations/versions/84c793a951b2_.py @@ -5,10 +5,11 @@ Create Date: 2019-11-12 20:04:46.065237 """ -from alembic import op -import sqlalchemy as sa + from datetime import datetime +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "84c793a951b2" diff --git a/migrations/versions/86c0f6b6a176_.py b/migrations/versions/86c0f6b6a176_.py index 299314bd53..e0d11ebdad 100644 --- a/migrations/versions/86c0f6b6a176_.py +++ b/migrations/versions/86c0f6b6a176_.py @@ -1,16 +1,16 @@ -""" add type field to organisation model +"""add type field to organisation model Revision ID: 86c0f6b6a176 Revises: 7937dae319b5 Create Date: 2021-02-09 03:29:03.763016 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from backend.models.postgis.statuses import OrganisationType - # revision identifiers, used by Alembic. revision = "86c0f6b6a176" down_revision = "7937dae319b5" diff --git a/migrations/versions/8a6419f289aa_.py b/migrations/versions/8a6419f289aa_.py index ee4ba37750..420c727bf7 100644 --- a/migrations/versions/8a6419f289aa_.py +++ b/migrations/versions/8a6419f289aa_.py @@ -5,9 +5,9 @@ Create Date: 2021-06-23 17:11:22.666814 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "8a6419f289aa" diff --git a/migrations/versions/8aa8f8d6a0c3_.py b/migrations/versions/8aa8f8d6a0c3_.py index 94a5d76611..84cdebb06a 100644 --- a/migrations/versions/8aa8f8d6a0c3_.py +++ b/migrations/versions/8aa8f8d6a0c3_.py @@ -5,12 +5,12 @@ Create Date: 2017-04-24 10:24:46.888136 """ -from alembic import op -import sqlalchemy as sa + import geoalchemy2 +import sqlalchemy as sa +from alembic import op from sqlalchemy import Integer - # revision identifiers, used by Alembic. revision = "8aa8f8d6a0c3" down_revision = None diff --git a/migrations/versions/8b61ac59bc57_.py b/migrations/versions/8b61ac59bc57_.py index 0663b8c550..a1597ab591 100644 --- a/migrations/versions/8b61ac59bc57_.py +++ b/migrations/versions/8b61ac59bc57_.py @@ -5,9 +5,9 @@ Create Date: 2022-06-27 09:00:04.602260 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "8b61ac59bc57" diff --git a/migrations/versions/8e5144b55919_.py b/migrations/versions/8e5144b55919_.py index 301c872407..39d4aa5543 100644 --- a/migrations/versions/8e5144b55919_.py +++ b/migrations/versions/8e5144b55919_.py @@ -4,9 +4,9 @@ Create Date: 2024-11-22 10:25:38.551015 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "8e5144b55919" diff --git a/migrations/versions/8f51fb7f4e40_.py b/migrations/versions/8f51fb7f4e40_.py index 7a2c820126..7767febe30 100644 --- a/migrations/versions/8f51fb7f4e40_.py +++ b/migrations/versions/8f51fb7f4e40_.py @@ -5,9 +5,9 @@ Create Date: 2019-05-01 16:09:21.471928 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "8f51fb7f4e40" diff --git a/migrations/versions/924a63857df4_.py b/migrations/versions/924a63857df4_.py index f57b5346ee..6546f45972 100644 --- a/migrations/versions/924a63857df4_.py +++ b/migrations/versions/924a63857df4_.py @@ -5,9 +5,9 @@ Create Date: 2022-04-28 13:32:15.595148 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "924a63857df4" diff --git a/migrations/versions/9712d29e24c8_.py b/migrations/versions/9712d29e24c8_.py index f6c66e92ba..a2f80ad62d 100644 --- a/migrations/versions/9712d29e24c8_.py +++ b/migrations/versions/9712d29e24c8_.py @@ -5,8 +5,8 @@ Create Date: 2020-07-28 21:13:50.314323 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "9712d29e24c8" diff --git a/migrations/versions/9f5b73af01db_.py b/migrations/versions/9f5b73af01db_.py index 9badcfd52c..1b8c84ff0d 100644 --- a/migrations/versions/9f5b73af01db_.py +++ b/migrations/versions/9f5b73af01db_.py @@ -5,8 +5,9 @@ Create Date: 2017-05-18 16:25:08.317568 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. diff --git a/migrations/versions/a43b9748ceee_.py b/migrations/versions/a43b9748ceee_.py index 010d5f621e..86b0b6c118 100644 --- a/migrations/versions/a43b9748ceee_.py +++ b/migrations/versions/a43b9748ceee_.py @@ -5,10 +5,11 @@ Create Date: 2019-06-12 12:50:15.809839 """ -from alembic import op + import sqlalchemy as sa -from backend.models.postgis.statuses import TaskStatus +from alembic import op +from backend.models.postgis.statuses import TaskStatus # revision identifiers, used by Alembic. revision = "a43b9748ceee" diff --git a/migrations/versions/a60315eb2654_.py b/migrations/versions/a60315eb2654_.py index 24f9a894e9..bce67c2986 100644 --- a/migrations/versions/a60315eb2654_.py +++ b/migrations/versions/a60315eb2654_.py @@ -5,8 +5,8 @@ Create Date: 2019-10-07 17:30:36.504327 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "a60315eb2654" diff --git a/migrations/versions/a7c617755721_.py b/migrations/versions/a7c617755721_.py index 7589a57ad7..a7016d6748 100644 --- a/migrations/versions/a7c617755721_.py +++ b/migrations/versions/a7c617755721_.py @@ -5,11 +5,11 @@ Create Date: 2017-04-26 11:22:15.384545 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from sqlalchemy import text - # revision identifiers, used by Alembic. revision = "a7c617755721" down_revision = "8aa8f8d6a0c3" diff --git a/migrations/versions/a8a7537985c0_.py b/migrations/versions/a8a7537985c0_.py index 4b31c9fbf2..bb33119a35 100644 --- a/migrations/versions/a8a7537985c0_.py +++ b/migrations/versions/a8a7537985c0_.py @@ -5,9 +5,9 @@ Create Date: 2019-05-09 15:45:43.492558 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "a8a7537985c0" diff --git a/migrations/versions/a9cbd2c6c213_.py b/migrations/versions/a9cbd2c6c213_.py index 3718d20bf1..c40ca5284c 100644 --- a/migrations/versions/a9cbd2c6c213_.py +++ b/migrations/versions/a9cbd2c6c213_.py @@ -5,9 +5,9 @@ Create Date: 2022-11-04 09:18:31.672823 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "a9cbd2c6c213" diff --git a/migrations/versions/ac55902fcc3d_.py b/migrations/versions/ac55902fcc3d_.py index 3b695db28a..26100ce280 100644 --- a/migrations/versions/ac55902fcc3d_.py +++ b/migrations/versions/ac55902fcc3d_.py @@ -5,9 +5,9 @@ Create Date: 2018-02-25 17:42:29.388863 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "ac55902fcc3d" diff --git a/migrations/versions/b25f088d40c2_.py b/migrations/versions/b25f088d40c2_.py index 1e7f79fdce..cbb2bd5f8f 100644 --- a/migrations/versions/b25f088d40c2_.py +++ b/migrations/versions/b25f088d40c2_.py @@ -5,8 +5,8 @@ Create Date: 2019-06-11 12:31:41.697842 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "b25f088d40c2" diff --git a/migrations/versions/b5b8370f9262_.py b/migrations/versions/b5b8370f9262_.py index 013df4e5f6..79645b0e7f 100644 --- a/migrations/versions/b5b8370f9262_.py +++ b/migrations/versions/b5b8370f9262_.py @@ -5,10 +5,10 @@ Create Date: 2017-05-04 12:08:56.957349 """ -from alembic import op -import sqlalchemy as sa -import geoalchemy2 +import geoalchemy2 +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "b5b8370f9262" diff --git a/migrations/versions/ba778ef9c615_.py b/migrations/versions/ba778ef9c615_.py index c6247e3ad5..ead87c93a8 100644 --- a/migrations/versions/ba778ef9c615_.py +++ b/migrations/versions/ba778ef9c615_.py @@ -5,9 +5,9 @@ Create Date: 2020-08-25 09:11:37.897176 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "ba778ef9c615" diff --git a/migrations/versions/badf8bb7d56b_.py b/migrations/versions/badf8bb7d56b_.py index d648ef6939..a0b86caaf9 100644 --- a/migrations/versions/badf8bb7d56b_.py +++ b/migrations/versions/badf8bb7d56b_.py @@ -5,6 +5,7 @@ Create Date: 2019-06-04 09:25:44.871107 """ + from alembic import op from sqlalchemy.dialects import postgresql diff --git a/migrations/versions/bcb474128817_.py b/migrations/versions/bcb474128817_.py index 815b1e5c29..1bf43f945c 100644 --- a/migrations/versions/bcb474128817_.py +++ b/migrations/versions/bcb474128817_.py @@ -5,9 +5,9 @@ Create Date: 2022-07-03 10:46:56.075805 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "bcb474128817" diff --git a/migrations/versions/bcdb7875bd1c_.py b/migrations/versions/bcdb7875bd1c_.py index c3a18ee18d..fca96f571d 100644 --- a/migrations/versions/bcdb7875bd1c_.py +++ b/migrations/versions/bcdb7875bd1c_.py @@ -5,8 +5,8 @@ Create Date: 2022-11-10 10:52:49.869498 """ -from alembic import op +from alembic import op # revision identifiers, used by Alembic. revision = "bcdb7875bd1c" diff --git a/migrations/versions/bfcf4182dcb5_.py b/migrations/versions/bfcf4182dcb5_.py index 63f34b5ae2..4ffb62beeb 100644 --- a/migrations/versions/bfcf4182dcb5_.py +++ b/migrations/versions/bfcf4182dcb5_.py @@ -5,12 +5,11 @@ Create Date: 2021-03-01 09:52:03.376234 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op from slugify import slugify - # revision identifiers, used by Alembic. revision = "bfcf4182dcb5" down_revision = "86c0f6b6a176" diff --git a/migrations/versions/c40e1fdf6b70_.py b/migrations/versions/c40e1fdf6b70_.py index 93d64de31e..445bc28dd2 100644 --- a/migrations/versions/c40e1fdf6b70_.py +++ b/migrations/versions/c40e1fdf6b70_.py @@ -5,8 +5,9 @@ Create Date: 2020-02-04 22:23:22.457001 """ -from alembic import op + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "c40e1fdf6b70" diff --git a/migrations/versions/c4670f3bb828_.py b/migrations/versions/c4670f3bb828_.py index 4a015be578..6ee1fc0acc 100644 --- a/migrations/versions/c4670f3bb828_.py +++ b/migrations/versions/c4670f3bb828_.py @@ -5,9 +5,9 @@ Create Date: 2017-05-03 13:59:37.296261 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "c4670f3bb828" diff --git a/migrations/versions/d2e18f0f34a9_.py b/migrations/versions/d2e18f0f34a9_.py index bbe42cd4fe..edb8b8ba18 100644 --- a/migrations/versions/d2e18f0f34a9_.py +++ b/migrations/versions/d2e18f0f34a9_.py @@ -5,9 +5,9 @@ Create Date: 2019-10-09 14:07:45.528774 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "d2e18f0f34a9" diff --git a/migrations/versions/d77ee40254f1_.py b/migrations/versions/d77ee40254f1_.py index 8339f0a2a3..361bf5cd98 100644 --- a/migrations/versions/d77ee40254f1_.py +++ b/migrations/versions/d77ee40254f1_.py @@ -5,8 +5,9 @@ Create Date: 2019-04-25 13:36:02.793208 """ -from alembic import op + import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. diff --git a/migrations/versions/dc250d726600_.py b/migrations/versions/dc250d726600_.py index 9cb55b873d..515bb84392 100644 --- a/migrations/versions/dc250d726600_.py +++ b/migrations/versions/dc250d726600_.py @@ -5,10 +5,10 @@ Create Date: 2017-05-29 10:14:06.958352 """ -from alembic import op -import sqlalchemy as sa -import geoalchemy2 +import geoalchemy2 +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "dc250d726600" diff --git a/migrations/versions/deec8123583d_.py b/migrations/versions/deec8123583d_.py index 36d5ebfa56..b901faf178 100644 --- a/migrations/versions/deec8123583d_.py +++ b/migrations/versions/deec8123583d_.py @@ -5,9 +5,9 @@ Create Date: 2018-08-07 23:09:58.621826 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "deec8123583d" diff --git a/migrations/versions/e0741bdfde1d_.py b/migrations/versions/e0741bdfde1d_.py index dd587bf0bc..c6cbdb7e2e 100644 --- a/migrations/versions/e0741bdfde1d_.py +++ b/migrations/versions/e0741bdfde1d_.py @@ -5,9 +5,10 @@ Create Date: 2017-05-29 14:22:30.597232 """ -from alembic import op -import sqlalchemy as sa + import geoalchemy2 +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "e0741bdfde1d" diff --git a/migrations/versions/e3282e2db2d7_.py b/migrations/versions/e3282e2db2d7_.py index 1f84d50733..c597e115d0 100644 --- a/migrations/versions/e3282e2db2d7_.py +++ b/migrations/versions/e3282e2db2d7_.py @@ -5,10 +5,11 @@ Create Date: 2019-06-17 18:34:11.058440 """ -from alembic import op + import sqlalchemy as sa -from backend.models.postgis.statuses import TeamVisibility +from alembic import op +from backend.models.postgis.statuses import TeamVisibility # revision identifiers, used by Alembic. revision = "e3282e2db2d7" @@ -25,7 +26,7 @@ def upgrade(): sa.Column("name", sa.String(length=512), nullable=False), sa.Column("logo", sa.String(), nullable=True), sa.Column("url", sa.String(), nullable=True), - sa.PrimaryKeyConstraint("id") + sa.PrimaryKeyConstraint("id"), # , # sa.UniqueConstraint("name"), ) diff --git a/migrations/versions/e36f1d0c4947_.py b/migrations/versions/e36f1d0c4947_.py index f1fee5dcf8..7e10c7f67c 100644 --- a/migrations/versions/e36f1d0c4947_.py +++ b/migrations/versions/e36f1d0c4947_.py @@ -5,9 +5,9 @@ Create Date: 2019-06-14 11:20:15.129237 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "e36f1d0c4947" diff --git a/migrations/versions/e8ffa33a9c18_.py b/migrations/versions/e8ffa33a9c18_.py index 9c392ab0da..adf0e6c033 100644 --- a/migrations/versions/e8ffa33a9c18_.py +++ b/migrations/versions/e8ffa33a9c18_.py @@ -5,9 +5,9 @@ Create Date: 2024-06-07 20:20:49.591011 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "e8ffa33a9c18" diff --git a/migrations/versions/ecb6985693c0_.py b/migrations/versions/ecb6985693c0_.py index b94ee7a371..514a34205a 100644 --- a/migrations/versions/ecb6985693c0_.py +++ b/migrations/versions/ecb6985693c0_.py @@ -1,4 +1,4 @@ -""" Add mapswipe group ID to partners table +"""Add mapswipe group ID to partners table Revision ID: ecb6985693c0_ Revises: 749a9ae35ce5 @@ -6,9 +6,8 @@ """ -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision = "ecb6985693c0_" diff --git a/migrations/versions/ee46f5e8723b_.py b/migrations/versions/ee46f5e8723b_.py index ac730cc25d..925de4e321 100644 --- a/migrations/versions/ee46f5e8723b_.py +++ b/migrations/versions/ee46f5e8723b_.py @@ -5,9 +5,9 @@ Create Date: 2019-07-14 16:27:37.368109 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "ee46f5e8723b" diff --git a/migrations/versions/ee5315dcf3e1_.py b/migrations/versions/ee5315dcf3e1_.py index e78b03d474..8ef0b86e66 100644 --- a/migrations/versions/ee5315dcf3e1_.py +++ b/migrations/versions/ee5315dcf3e1_.py @@ -5,9 +5,9 @@ Create Date: 2017-05-24 10:39:46.586986 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "ee5315dcf3e1" diff --git a/migrations/versions/f26a7c36eb65_.py b/migrations/versions/f26a7c36eb65_.py index 403b15c52d..924c22f32e 100644 --- a/migrations/versions/f26a7c36eb65_.py +++ b/migrations/versions/f26a7c36eb65_.py @@ -5,9 +5,9 @@ Create Date: 2019-11-28 04:44:32.764819 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "f26a7c36eb65" diff --git a/migrations/versions/f547382df31b_.py b/migrations/versions/f547382df31b_.py index c950daf5ad..4c71f8dba8 100644 --- a/migrations/versions/f547382df31b_.py +++ b/migrations/versions/f547382df31b_.py @@ -5,9 +5,9 @@ Create Date: 2019-10-07 07:06:12.394963 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "f547382df31b" diff --git a/migrations/versions/f86698c827cc_.py b/migrations/versions/f86698c827cc_.py index 8ae21cfd1c..d13ca50106 100644 --- a/migrations/versions/f86698c827cc_.py +++ b/migrations/versions/f86698c827cc_.py @@ -5,9 +5,9 @@ Create Date: 2019-11-06 11:46:48.616033 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "f86698c827cc" diff --git a/migrations/versions/fcd9cebaa79c_.py b/migrations/versions/fcd9cebaa79c_.py index 8668b9aabf..99d6778a15 100644 --- a/migrations/versions/fcd9cebaa79c_.py +++ b/migrations/versions/fcd9cebaa79c_.py @@ -5,9 +5,9 @@ Create Date: 2019-05-07 11:06:50.293370 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "fcd9cebaa79c" diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity index 3adb8894d2..6c8cd20f44 100644 --- a/node_modules/.yarn-integrity +++ b/node_modules/.yarn-integrity @@ -1,5 +1,5 @@ { - "systemParams": "darwin-arm64-115", + "systemParams": "darwin-x64-131", "modulesFolders": [ "node_modules" ], diff --git a/pdm.lock b/pdm.lock deleted file mode 100644 index 2a1f53d365..0000000000 --- a/pdm.lock +++ /dev/null @@ -1,1458 +0,0 @@ -# This file is @generated by PDM. -# It is not intended for manual editing. - -[metadata] -groups = ["default", "dev", "lint", "test"] -strategy = ["cross_platform", "inherit_metadata"] -lock_version = "4.5.0" -content_hash = "sha256:cc71db9fff34bb60941d621046a2b8b25eba61a67353e9ae05f0416376cc56d8" - -[[metadata.targets]] -requires_python = ">=3.9,<=3.11" - -[[package]] -name = "alembic" -version = "1.11.1" -requires_python = ">=3.7" -summary = "A database migration tool for SQLAlchemy." -groups = ["default"] -dependencies = [ - "Mako", - "SQLAlchemy>=1.3.0", - "importlib-metadata; python_version < \"3.9\"", - "importlib-resources; python_version < \"3.9\"", - "typing-extensions>=4", -] -files = [ - {file = "alembic-1.11.1-py3-none-any.whl", hash = "sha256:dc871798a601fab38332e38d6ddb38d5e734f60034baeb8e2db5b642fccd8ab8"}, - {file = "alembic-1.11.1.tar.gz", hash = "sha256:6a810a6b012c88b33458fceb869aef09ac75d6ace5291915ba7fae44de372c01"}, -] - -[[package]] -name = "aniso8601" -version = "9.0.1" -summary = "A library for parsing ISO 8601 strings." -groups = ["default"] -files = [ - {file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"}, - {file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"}, -] - -[[package]] -name = "apscheduler" -version = "3.10.1" -requires_python = ">=3.6" -summary = "In-process task scheduler with Cron-like capabilities" -groups = ["default"] -dependencies = [ - "pytz", - "setuptools>=0.7", - "six>=1.4.0", - "tzlocal!=3.*,>=2.0", -] -files = [ - {file = "APScheduler-3.10.1-py3-none-any.whl", hash = "sha256:e813ad5ada7aff36fb08cdda746b520531eaac7757832abc204868ba78e0c8f6"}, - {file = "APScheduler-3.10.1.tar.gz", hash = "sha256:0293937d8f6051a0f493359440c1a1b93e882c57daf0197afeff0e727777b96e"}, -] - -[[package]] -name = "black" -version = "23.7.0" -requires_python = ">=3.8" -summary = "The uncompromising code formatter." -groups = ["lint"] -dependencies = [ - "click>=8.0.0", - "mypy-extensions>=0.4.3", - "packaging>=22.0", - "pathspec>=0.9.0", - "platformdirs>=2", - "tomli>=1.1.0; python_version < \"3.11\"", - "typing-extensions>=3.10.0.0; python_version < \"3.10\"", -] -files = [ - {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, - {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, - {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, - {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, - {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, - {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, - {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, -] - -[[package]] -name = "bleach" -version = "6.0.0" -requires_python = ">=3.7" -summary = "An easy safelist-based HTML-sanitizing tool." -groups = ["default"] -dependencies = [ - "six>=1.9.0", - "webencodings", -] -files = [ - {file = "bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4"}, - {file = "bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414"}, -] - -[[package]] -name = "blinker" -version = "1.6.2" -requires_python = ">=3.7" -summary = "Fast, simple object-to-object and broadcast signaling" -groups = ["default"] -files = [ - {file = "blinker-1.6.2-py3-none-any.whl", hash = "sha256:c3d739772abb7bc2860abf5f2ec284223d9ad5c76da018234f6f50d6f31ab1f0"}, - {file = "blinker-1.6.2.tar.gz", hash = "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213"}, -] - -[[package]] -name = "cachetools" -version = "5.3.1" -requires_python = ">=3.7" -summary = "Extensible memoizing collections and decorators" -groups = ["default"] -files = [ - {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, - {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, -] - -[[package]] -name = "certifi" -version = "2023.5.7" -requires_python = ">=3.6" -summary = "Python package for providing Mozilla's CA Bundle." -groups = ["default"] -files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, -] - -[[package]] -name = "cffi" -version = "1.15.1" -summary = "Foreign Function Interface for Python calling C code." -groups = ["default"] -marker = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\"" -dependencies = [ - "pycparser", -] -files = [ - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.1.0" -requires_python = ">=3.7.0" -summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["default"] -files = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, -] - -[[package]] -name = "click" -version = "8.1.3" -requires_python = ">=3.7" -summary = "Composable command line interface toolkit" -groups = ["default", "lint"] -dependencies = [ - "colorama; platform_system == \"Windows\"", - "importlib-metadata; python_version < \"3.8\"", -] -files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -summary = "Cross-platform colored terminal text." -groups = ["default", "lint", "test"] -marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.2.7" -requires_python = ">=3.7" -summary = "Code coverage measurement for Python" -groups = ["test"] -files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.1.1" -requires_python = ">=3.7" -summary = "Backport of PEP 654 (exception groups)" -groups = ["test"] -marker = "python_version < \"3.11\"" -files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, -] - -[[package]] -name = "flake8" -version = "6.1.0" -requires_python = ">=3.8.1" -summary = "the modular source code checker: pep8 pyflakes and co" -groups = ["lint"] -dependencies = [ - "mccabe<0.8.0,>=0.7.0", - "pycodestyle<2.12.0,>=2.11.0", - "pyflakes<3.2.0,>=3.1.0", -] -files = [ - {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, - {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, -] - -[[package]] -name = "flask" -version = "2.3.2" -requires_python = ">=3.8" -summary = "A simple framework for building complex web applications." -groups = ["default"] -dependencies = [ - "Jinja2>=3.1.2", - "Werkzeug>=2.3.3", - "blinker>=1.6.2", - "click>=8.1.3", - "importlib-metadata>=3.6.0; python_version < \"3.10\"", - "itsdangerous>=2.1.2", -] -files = [ - {file = "Flask-2.3.2-py3-none-any.whl", hash = "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0"}, - {file = "Flask-2.3.2.tar.gz", hash = "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef"}, -] - -[[package]] -name = "flask-cors" -version = "4.0.0" -summary = "A Flask extension adding a decorator for CORS support" -groups = ["default"] -dependencies = [ - "Flask>=0.9", -] -files = [ - {file = "Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0"}, - {file = "Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783"}, -] - -[[package]] -name = "flask-httpauth" -version = "4.8.0" -summary = "HTTP authentication for Flask routes" -groups = ["default"] -dependencies = [ - "flask", -] -files = [ - {file = "Flask-HTTPAuth-4.8.0.tar.gz", hash = "sha256:66568a05bc73942c65f1e2201ae746295816dc009edd84b482c44c758d75097a"}, - {file = "Flask_HTTPAuth-4.8.0-py3-none-any.whl", hash = "sha256:a58fedd09989b9975448eef04806b096a3964a7feeebc0a78831ff55685b62b0"}, -] - -[[package]] -name = "flask-mail" -version = "0.9.1" -summary = "Flask extension for sending email" -groups = ["default"] -dependencies = [ - "Flask", - "blinker", -] -files = [ - {file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"}, -] - -[[package]] -name = "flask-migrate" -version = "4.0.4" -requires_python = ">=3.6" -summary = "SQLAlchemy database migrations for Flask applications using Alembic." -groups = ["default"] -dependencies = [ - "Flask-SQLAlchemy>=1.0", - "Flask>=0.9", - "alembic>=1.9.0", -] -files = [ - {file = "Flask-Migrate-4.0.4.tar.gz", hash = "sha256:73293d40b10ac17736e715b377e7b7bde474cb8105165d77474df4c3619b10b3"}, - {file = "Flask_Migrate-4.0.4-py3-none-any.whl", hash = "sha256:77580f27ab39bc68be4906a43c56d7674b45075bc4f883b1d0b985db5164d58f"}, -] - -[[package]] -name = "flask-restful" -version = "0.3.10" -summary = "Simple framework for creating REST APIs" -groups = ["default"] -dependencies = [ - "Flask>=0.8", - "aniso8601>=0.82", - "pytz", - "six>=1.3.0", -] -files = [ - {file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"}, - {file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"}, -] - -[[package]] -name = "flask-sqlalchemy" -version = "3.0.5" -requires_python = ">=3.7" -summary = "Add SQLAlchemy support to your Flask application." -groups = ["default"] -dependencies = [ - "flask>=2.2.5", - "sqlalchemy>=1.4.18", -] -files = [ - {file = "flask_sqlalchemy-3.0.5-py3-none-any.whl", hash = "sha256:cabb6600ddd819a9f859f36515bb1bd8e7dbf30206cc679d2b081dff9e383283"}, - {file = "flask_sqlalchemy-3.0.5.tar.gz", hash = "sha256:c5765e58ca145401b52106c0f46178569243c5da25556be2c231ecc60867c5b1"}, -] - -[[package]] -name = "flask-swagger" -version = "0.2.14" -summary = "Extract swagger specs from your flask project" -groups = ["default"] -dependencies = [ - "Flask>=0.10", - "PyYAML>=5.1", -] -files = [ - {file = "flask-swagger-0.2.14.tar.gz", hash = "sha256:b4085f5bc36df4c20b6548cd1413adc9cf35719b0f0695367cd542065145294d"}, -] - -[[package]] -name = "geoalchemy2" -version = "0.14.1" -requires_python = ">=3.7" -summary = "Using SQLAlchemy with Spatial Databases" -groups = ["default"] -dependencies = [ - "SQLAlchemy>=1.4", - "packaging", -] -files = [ - {file = "GeoAlchemy2-0.14.1-py3-none-any.whl", hash = "sha256:0830c98f83d6b1706e62b5544793d304e2853493d6e70ac18444c13748c3d1c7"}, - {file = "GeoAlchemy2-0.14.1.tar.gz", hash = "sha256:620b31cbf97a368b2486dbcfcd36da2081827e933d4163bcb942043b79b545e8"}, -] - -[[package]] -name = "geojson" -version = "3.0.1" -requires_python = ">=3.7, <3.12" -summary = "Python bindings and utilities for GeoJSON" -groups = ["default"] -files = [ - {file = "geojson-3.0.1-py3-none-any.whl", hash = "sha256:e49df982b204ed481e4c1236c57f587adf71537301cf8faf7120ab27d73c7568"}, - {file = "geojson-3.0.1.tar.gz", hash = "sha256:ff3d75acab60b1e66504a11f7ea12c104bad32ff3c410a807788663b966dee4a"}, -] - -[[package]] -name = "gevent" -version = "22.10.2" -requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5" -summary = "Coroutine-based network library" -groups = ["default"] -dependencies = [ - "cffi>=1.12.2; platform_python_implementation == \"CPython\" and sys_platform == \"win32\"", - "greenlet>=2.0.0; platform_python_implementation == \"CPython\"", - "setuptools", - "zope-event", - "zope-interface", -] -files = [ - {file = "gevent-22.10.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:990d7069f14dc40674e0d5cb43c68fd3bad8337048613b9bb94a0c4180ffc176"}, - {file = "gevent-22.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f23d0997149a816a2a9045af29c66f67f405a221745b34cefeac5769ed451db8"}, - {file = "gevent-22.10.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b43d500d7d3c0e03070dee813335bb5315215aa1cf6a04c61093dfdd718640b3"}, - {file = "gevent-22.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b68f4c9e20e47ad49fe797f37f91d5bbeace8765ce2707f979a8d4ec197e4d"}, - {file = "gevent-22.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1f001cac0ba8da76abfeb392a3057f81fab3d67cc916c7df8ea977a44a2cc989"}, - {file = "gevent-22.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:3b7eae8a0653ba95a224faaddf629a913ace408edb67384d3117acf42d7dcf89"}, - {file = "gevent-22.10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8f2477e7b0a903a01485c55bacf2089110e5f767014967ba4b287ff390ae2638"}, - {file = "gevent-22.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddaa3e310a8f1a45b5c42cf50b54c31003a3028e7d4e085059090ea0e7a5fddd"}, - {file = "gevent-22.10.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98bc510e80f45486ef5b806a1c305e0e89f0430688c14984b0dbdec03331f48b"}, - {file = "gevent-22.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:877abdb3a669576b1d51ce6a49b7260b2a96f6b2424eb93287e779a3219d20ba"}, - {file = "gevent-22.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d21ad79cca234cdbfa249e727500b0ddcbc7adfff6614a96e6eaa49faca3e4f2"}, - {file = "gevent-22.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e955238f59b2947631c9782a713280dd75884e40e455313b5b6bbc20b92ff73"}, - {file = "gevent-22.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58898dbabb5b11e4d0192aae165ad286dc6742c543e1be9d30dc82753547c508"}, - {file = "gevent-22.10.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:4197d423e198265eef39a0dea286ef389da9148e070310f34455ecee8172c391"}, - {file = "gevent-22.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da4183f0b9d9a1e25e1758099220d32c51cc2c6340ee0dea3fd236b2b37598e4"}, - {file = "gevent-22.10.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5488eba6a568b4d23c072113da4fc0feb1b5f5ede7381656dc913e0d82204e2"}, - {file = "gevent-22.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:319d8b1699b7b8134de66d656cd739b308ab9c45ace14d60ae44de7775b456c9"}, - {file = "gevent-22.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f3329bedbba4d3146ae58c667e0f9ac1e6f1e1e6340c7593976cdc60aa7d1a47"}, - {file = "gevent-22.10.2-cp39-cp39-win32.whl", hash = "sha256:172caa66273315f283e90a315921902cb6549762bdcb0587fd60cb712a9d6263"}, - {file = "gevent-22.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:323b207b281ba0405fea042067fa1a61662e5ac0d574ede4ebbda03efd20c350"}, - {file = "gevent-22.10.2-pp27-pypy_73-win_amd64.whl", hash = "sha256:ed7f16613eebf892a6a744d7a4a8f345bc6f066a0ff3b413e2479f9c0a180193"}, - {file = "gevent-22.10.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a47a4e77e2bc668856aad92a0b8de7ee10768258d93cd03968e6c7ba2e832f76"}, - {file = "gevent-22.10.2.tar.gz", hash = "sha256:1ca01da176ee37b3527a2702f7d40dbc9ffb8cfc7be5a03bfa4f9eec45e55c46"}, -] - -[[package]] -name = "greenlet" -version = "2.0.2" -requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -summary = "Lightweight in-process concurrent programming" -groups = ["default"] -files = [ - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, - {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, - {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, - {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, - {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, - {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, -] - -[[package]] -name = "gunicorn" -version = "20.1.0" -requires_python = ">=3.5" -summary = "WSGI HTTP Server for UNIX" -groups = ["default"] -dependencies = [ - "setuptools>=3.0", -] -files = [ - {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, - {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, -] - -[[package]] -name = "gunicorn" -version = "20.1.0" -extras = ["gevent"] -requires_python = ">=3.5" -summary = "WSGI HTTP Server for UNIX" -groups = ["default"] -dependencies = [ - "gevent>=1.4.0", - "gunicorn==20.1.0", -] -files = [ - {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, - {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, -] - -[[package]] -name = "idna" -version = "3.4" -requires_python = ">=3.5" -summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default"] -files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] - -[[package]] -name = "importlib-metadata" -version = "6.8.0" -requires_python = ">=3.8" -summary = "Read metadata from Python packages" -groups = ["default"] -dependencies = [ - "typing-extensions>=3.6.4; python_version < \"3.8\"", - "zipp>=0.5", -] -files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, -] - -[[package]] -name = "iniconfig" -version = "2.0.0" -requires_python = ">=3.7" -summary = "brain-dead simple config-ini parsing" -groups = ["test"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "itsdangerous" -version = "2.1.2" -requires_python = ">=3.7" -summary = "Safely pass data to untrusted environments and back." -groups = ["default"] -files = [ - {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, - {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, -] - -[[package]] -name = "jinja2" -version = "3.1.2" -requires_python = ">=3.7" -summary = "A very fast and expressive template engine." -groups = ["default"] -dependencies = [ - "MarkupSafe>=2.0", -] -files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] - -[[package]] -name = "joblib" -version = "1.2.0" -requires_python = ">=3.7" -summary = "Lightweight pipelining with Python functions" -groups = ["default"] -files = [ - {file = "joblib-1.2.0-py3-none-any.whl", hash = "sha256:091138ed78f800342968c523bdde947e7a305b8594b910a0fea2ab83c3c6d385"}, - {file = "joblib-1.2.0.tar.gz", hash = "sha256:e1cee4a79e4af22881164f218d4311f60074197fb707e082e803b61f6d137018"}, -] - -[[package]] -name = "mako" -version = "1.2.4" -requires_python = ">=3.7" -summary = "A super-fast templating language that borrows the best ideas from the existing templating languages." -groups = ["default"] -dependencies = [ - "MarkupSafe>=0.9.2", - "importlib-metadata; python_version < \"3.8\"", -] -files = [ - {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, - {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, -] - -[[package]] -name = "markdown" -version = "3.4.4" -requires_python = ">=3.7" -summary = "Python implementation of John Gruber's Markdown." -groups = ["default"] -dependencies = [ - "importlib-metadata>=4.4; python_version < \"3.10\"", -] -files = [ - {file = "Markdown-3.4.4-py3-none-any.whl", hash = "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941"}, - {file = "Markdown-3.4.4.tar.gz", hash = "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6"}, -] - -[[package]] -name = "markupsafe" -version = "2.1.2" -requires_python = ">=3.7" -summary = "Safely add untrusted strings to HTML/XML markup." -groups = ["default"] -files = [ - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, - {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, - {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, - {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, - {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -requires_python = ">=3.6" -summary = "McCabe checker, plugin for flake8" -groups = ["lint"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -requires_python = ">=3.5" -summary = "Type system extensions for programs checked with the mypy type checker." -groups = ["lint"] -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "newrelic" -version = "8.8.0" -requires_python = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -summary = "New Relic Python Agent" -groups = ["default"] -files = [ - {file = "newrelic-8.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b4db0e7544232d4e6e835a02ee28637970576f8dce82ffcaa3d675246e822d5"}, - {file = "newrelic-8.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9355f209ba8d82fd0f9d78d7cc1d9bef0ae4677b3cfed7b7aaec521adbe87559"}, - {file = "newrelic-8.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4a0556c6ece49132ab1c32bfe398047a8311f9a8b6862b482495d132fcb0ad4"}, - {file = "newrelic-8.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caccdf201735df80b470ddf772f60a154f2c07c0c1b2b3f6e999d55e79ce601e"}, - {file = "newrelic-8.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:796ed5ff44b04b41e051dc0112e5016e53a37e39e95023c45ff7ecd34c254a7d"}, - {file = "newrelic-8.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcd3219e1e816a0fdb51ac993cac6744e6a835c13ee72e21d86bcbc2d16628ce"}, - {file = "newrelic-8.8.0.tar.gz", hash = "sha256:84d1f71284efa5f1cae696161e0c3cb65eaa2f53116fe5e7c5a62be7d15d9536"}, -] - -[[package]] -name = "numpy" -version = "1.24.3" -requires_python = ">=3.8" -summary = "Fundamental package for array computing in Python" -groups = ["default"] -files = [ - {file = "numpy-1.24.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c1104d3c036fb81ab923f507536daedc718d0ad5a8707c6061cdfd6d184e570"}, - {file = "numpy-1.24.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:202de8f38fc4a45a3eea4b63e2f376e5f2dc64ef0fa692838e31a808520efaf7"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8535303847b89aa6b0f00aa1dc62867b5a32923e4d1681a35b5eef2d9591a463"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d926b52ba1367f9acb76b0df6ed21f0b16a1ad87c6720a1121674e5cf63e2b6"}, - {file = "numpy-1.24.3-cp310-cp310-win32.whl", hash = "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b"}, - {file = "numpy-1.24.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f23af8c16022663a652d3b25dcdc272ac3f83c3af4c02eb8b824e6b3ab9d7"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a7721ec204d3a237225db3e194c25268faf92e19338a35f3a224469cb6039a3"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d6cc757de514c00b24ae8cf5c876af2a7c3df189028d68c0cb4eaa9cd5afc2bf"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e3f4e85fc5d4fd311f6e9b794d0c00e7002ec122be271f2019d63376f1d385"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1d3c026f57ceaad42f8231305d4653d5f05dc6332a730ae5c0bea3513de0950"}, - {file = "numpy-1.24.3-cp311-cp311-win32.whl", hash = "sha256:c91c4afd8abc3908e00a44b2672718905b8611503f7ff87390cc0ac3423fb096"}, - {file = "numpy-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:5342cf6aad47943286afa6f1609cad9b4266a05e7f2ec408e2cf7aea7ff69d80"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4719d5aefb5189f50887773699eaf94e7d1e02bf36c1a9d353d9f46703758ca4"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ec87a7084caa559c36e0a2309e4ecb1baa03b687201d0a847c8b0ed476a7187"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea8282b9bcfe2b5e7d491d0bf7f3e2da29700cec05b49e64d6246923329f2b02"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210461d87fb02a84ef243cac5e814aad2b7f4be953b32cb53327bb49fd77fbb4"}, - {file = "numpy-1.24.3-cp39-cp39-win32.whl", hash = "sha256:784c6da1a07818491b0ffd63c6bbe5a33deaa0e25a20e1b3ea20cf0e43f8046c"}, - {file = "numpy-1.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:d5036197ecae68d7f491fcdb4df90082b0d4960ca6599ba2659957aafced7c17"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:352ee00c7f8387b44d19f4cada524586f07379c0d49270f87233983bc5087ca0"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d6acc2e7524c9955e5c903160aa4ea083736fde7e91276b0e5d98e6332812"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:35400e6a8d102fd07c71ed7dcadd9eb62ee9a6e84ec159bd48c28235bbb0f8e4"}, - {file = "numpy-1.24.3.tar.gz", hash = "sha256:ab344f1bf21f140adab8e47fdbc7c35a477dc01408791f8ba00d018dd0bc5155"}, -] - -[[package]] -name = "oauthlib" -version = "3.2.2" -requires_python = ">=3.6" -summary = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -groups = ["default"] -files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, -] - -[[package]] -name = "packaging" -version = "23.1" -requires_python = ">=3.7" -summary = "Core utilities for Python packages" -groups = ["default", "lint", "test"] -files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] - -[[package]] -name = "pandas" -version = "2.0.2" -requires_python = ">=3.8" -summary = "Powerful data structures for data analysis, time series, and statistics" -groups = ["default"] -dependencies = [ - "numpy>=1.20.3; python_version < \"3.10\"", - "numpy>=1.21.0; python_version >= \"3.10\"", - "numpy>=1.23.2; python_version >= \"3.11\"", - "python-dateutil>=2.8.2", - "pytz>=2020.1", - "tzdata>=2022.1", -] -files = [ - {file = "pandas-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ebb9f1c22ddb828e7fd017ea265a59d80461d5a79154b49a4207bd17514d122"}, - {file = "pandas-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eb09a242184092f424b2edd06eb2b99d06dc07eeddff9929e8667d4ed44e181"}, - {file = "pandas-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7319b6e68de14e6209460f72a8d1ef13c09fb3d3ef6c37c1e65b35d50b5c145"}, - {file = "pandas-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd46bde7309088481b1cf9c58e3f0e204b9ff9e3244f441accd220dd3365ce7c"}, - {file = "pandas-2.0.2-cp310-cp310-win32.whl", hash = "sha256:51a93d422fbb1bd04b67639ba4b5368dffc26923f3ea32a275d2cc450f1d1c86"}, - {file = "pandas-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:66d00300f188fa5de73f92d5725ced162488f6dc6ad4cecfe4144ca29debe3b8"}, - {file = "pandas-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02755de164da6827764ceb3bbc5f64b35cb12394b1024fdf88704d0fa06e0e2f"}, - {file = "pandas-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0a1e0576611641acde15c2322228d138258f236d14b749ad9af498ab69089e2d"}, - {file = "pandas-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6b5f14cd24a2ed06e14255ff40fe2ea0cfaef79a8dd68069b7ace74bd6acbba"}, - {file = "pandas-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50e451932b3011b61d2961b4185382c92cc8c6ee4658dcd4f320687bb2d000ee"}, - {file = "pandas-2.0.2-cp311-cp311-win32.whl", hash = "sha256:7b21cb72958fc49ad757685db1919021d99650d7aaba676576c9e88d3889d456"}, - {file = "pandas-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:c4af689352c4fe3d75b2834933ee9d0ccdbf5d7a8a7264f0ce9524e877820c08"}, - {file = "pandas-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b42b120458636a981077cfcfa8568c031b3e8709701315e2bfa866324a83efa8"}, - {file = "pandas-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f908a77cbeef9bbd646bd4b81214cbef9ac3dda4181d5092a4aa9797d1bc7774"}, - {file = "pandas-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713f2f70abcdade1ddd68fc91577cb090b3544b07ceba78a12f799355a13ee44"}, - {file = "pandas-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf3f0c361a4270185baa89ec7ab92ecaa355fe783791457077473f974f654df5"}, - {file = "pandas-2.0.2-cp39-cp39-win32.whl", hash = "sha256:598e9020d85a8cdbaa1815eb325a91cfff2bb2b23c1442549b8a3668e36f0f77"}, - {file = "pandas-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:77550c8909ebc23e56a89f91b40ad01b50c42cfbfab49b3393694a50549295ea"}, - {file = "pandas-2.0.2.tar.gz", hash = "sha256:dd5476b6c3fe410ee95926873f377b856dbc4e81a9c605a0dc05aaccc6a7c6c6"}, -] - -[[package]] -name = "pathspec" -version = "0.11.1" -requires_python = ">=3.7" -summary = "Utility library for gitignore style pattern matching of file paths." -groups = ["lint"] -files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, -] - -[[package]] -name = "platformdirs" -version = "3.5.1" -requires_python = ">=3.7" -summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -groups = ["lint"] -dependencies = [ - "typing-extensions>=4.5; python_version < \"3.8\"", -] -files = [ - {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, - {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, -] - -[[package]] -name = "pluggy" -version = "1.0.0" -requires_python = ">=3.6" -summary = "plugin and hook calling mechanisms for python" -groups = ["test"] -dependencies = [ - "importlib-metadata>=0.12; python_version < \"3.8\"", -] -files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] - -[[package]] -name = "psycopg2" -version = "2.9.6" -requires_python = ">=3.6" -summary = "psycopg2 - Python-PostgreSQL Database Adapter" -groups = ["default"] -files = [ - {file = "psycopg2-2.9.6-cp310-cp310-win32.whl", hash = "sha256:f7a7a5ee78ba7dc74265ba69e010ae89dae635eea0e97b055fb641a01a31d2b1"}, - {file = "psycopg2-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:f75001a1cbbe523e00b0ef896a5a1ada2da93ccd752b7636db5a99bc57c44494"}, - {file = "psycopg2-2.9.6-cp311-cp311-win32.whl", hash = "sha256:53f4ad0a3988f983e9b49a5d9765d663bbe84f508ed655affdb810af9d0972ad"}, - {file = "psycopg2-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b81fcb9ecfc584f661b71c889edeae70bae30d3ef74fa0ca388ecda50b1222b7"}, - {file = "psycopg2-2.9.6-cp39-cp39-win32.whl", hash = "sha256:1861a53a6a0fd248e42ea37c957d36950da00266378746588eab4f4b5649e95f"}, - {file = "psycopg2-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:ded2faa2e6dfb430af7713d87ab4abbfc764d8d7fb73eafe96a24155f906ebf5"}, - {file = "psycopg2-2.9.6.tar.gz", hash = "sha256:f15158418fd826831b28585e2ab48ed8df2d0d98f502a2b4fe619e7d5ca29011"}, -] - -[[package]] -name = "psycopg2-binary" -version = "2.9.6" -requires_python = ">=3.6" -summary = "psycopg2 - Python-PostgreSQL Database Adapter" -groups = ["dev"] -files = [ - {file = "psycopg2-binary-2.9.6.tar.gz", hash = "sha256:1f64dcfb8f6e0c014c7f55e51c9759f024f70ea572fbdef123f85318c297947c"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d26e0342183c762de3276cca7a530d574d4e25121ca7d6e4a98e4f05cb8e4df7"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c48d8f2db17f27d41fb0e2ecd703ea41984ee19362cbce52c097963b3a1b4365"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a76e027f87753f9bd1ab5f7c9cb8c7628d1077ef927f5e2446477153a602f2c"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6460c7a99fc939b849431f1e73e013d54aa54293f30f1109019c56a0b2b2ec2f"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae102a98c547ee2288637af07393dd33f440c25e5cd79556b04e3fca13325e5f"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9972aad21f965599ed0106f65334230ce826e5ae69fda7cbd688d24fa922415e"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a40c00dbe17c0af5bdd55aafd6ff6679f94a9be9513a4c7e071baf3d7d22a70"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cacbdc5839bdff804dfebc058fe25684cae322987f7a38b0168bc1b2df703fb1"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f0438fa20fb6c7e202863e0d5ab02c246d35efb1d164e052f2f3bfe2b152bd0"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-win32.whl", hash = "sha256:b6c8288bb8a84b47e07013bb4850f50538aa913d487579e1921724631d02ea1b"}, - {file = "psycopg2_binary-2.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:61b047a0537bbc3afae10f134dc6393823882eb263088c271331602b672e52e9"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:964b4dfb7c1c1965ac4c1978b0f755cc4bd698e8aa2b7667c575fb5f04ebe06b"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afe64e9b8ea66866a771996f6ff14447e8082ea26e675a295ad3bdbffdd72afb"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e2ee79e7cf29582ef770de7dab3d286431b01c3bb598f8e05e09601b890081"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa74c903a3c1f0d9b1c7e7b53ed2d929a4910e272add6700c38f365a6002820"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83456c2d4979e08ff56180a76429263ea254c3f6552cd14ada95cff1dec9bb8"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0645376d399bfd64da57148694d78e1f431b1e1ee1054872a5713125681cf1be"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99e34c82309dd78959ba3c1590975b5d3c862d6f279f843d47d26ff89d7d7e1"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4ea29fc3ad9d91162c52b578f211ff1c931d8a38e1f58e684c45aa470adf19e2"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:4ac30da8b4f57187dbf449294d23b808f8f53cad6b1fc3623fa8a6c11d176dd0"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e78e6e2a00c223e164c417628572a90093c031ed724492c763721c2e0bc2a8df"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-win32.whl", hash = "sha256:1876843d8e31c89c399e31b97d4b9725a3575bb9c2af92038464231ec40f9edb"}, - {file = "psycopg2_binary-2.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:b4b24f75d16a89cc6b4cdff0eb6a910a966ecd476d1e73f7ce5985ff1328e9a6"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e9182eb20f41417ea1dd8e8f7888c4d7c6e805f8a7c98c1081778a3da2bee3e4"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a6979cf527e2603d349a91060f428bcb135aea2be3201dff794813256c274f1"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8338a271cb71d8da40b023a35d9c1e919eba6cbd8fa20a54b748a332c355d896"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ed340d2b858d6e6fb5083f87c09996506af483227735de6964a6100b4e6a54"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81e65376e52f03422e1fb475c9514185669943798ed019ac50410fb4c4df232"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb13af3c5dd3a9588000910178de17010ebcccd37b4f9794b00595e3a8ddad3"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4c727b597c6444a16e9119386b59388f8a424223302d0c06c676ec8b4bc1f963"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d67fbdaf177da06374473ef6f7ed8cc0a9dc640b01abfe9e8a2ccb1b1402c1f"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0892ef645c2fabb0c75ec32d79f4252542d0caec1d5d949630e7d242ca4681a3"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:02c0f3757a4300cf379eb49f543fb7ac527fb00144d39246ee40e1df684ab514"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-win32.whl", hash = "sha256:c3dba7dab16709a33a847e5cd756767271697041fbe3fe97c215b1fc1f5c9848"}, - {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, -] - -[[package]] -name = "pycodestyle" -version = "2.11.0" -requires_python = ">=3.8" -summary = "Python style guide checker" -groups = ["lint"] -files = [ - {file = "pycodestyle-2.11.0-py2.py3-none-any.whl", hash = "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8"}, - {file = "pycodestyle-2.11.0.tar.gz", hash = "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0"}, -] - -[[package]] -name = "pycparser" -version = "2.21" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -summary = "C parser in Python" -groups = ["default"] -marker = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\"" -files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] - -[[package]] -name = "pyflakes" -version = "3.1.0" -requires_python = ">=3.8" -summary = "passive checker of Python programs" -groups = ["lint"] -files = [ - {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, - {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, -] - -[[package]] -name = "pytest" -version = "7.4.0" -requires_python = ">=3.7" -summary = "pytest: simple powerful testing with Python" -groups = ["test"] -dependencies = [ - "colorama; sys_platform == \"win32\"", - "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", - "importlib-metadata>=0.12; python_version < \"3.8\"", - "iniconfig", - "packaging", - "pluggy<2.0,>=0.12", - "tomli>=1.0.0; python_version < \"3.11\"", -] -files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, -] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Extensions to the standard Python datetime module" -groups = ["default"] -dependencies = [ - "six>=1.5", -] -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[[package]] -name = "python-dotenv" -version = "1.0.0" -requires_python = ">=3.8" -summary = "Read key-value pairs from a .env file and set them as environment variables" -groups = ["default"] -files = [ - {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, - {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, -] - -[[package]] -name = "python-slugify" -version = "8.0.1" -requires_python = ">=3.7" -summary = "A Python slugify application that also handles Unicode" -groups = ["default"] -dependencies = [ - "text-unidecode>=1.3", -] -files = [ - {file = "python-slugify-8.0.1.tar.gz", hash = "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27"}, - {file = "python_slugify-8.0.1-py2.py3-none-any.whl", hash = "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395"}, -] - -[[package]] -name = "pytz" -version = "2023.3" -summary = "World timezone definitions, modern and historical" -groups = ["default"] -files = [ - {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, - {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.1" -requires_python = ">=3.6" -summary = "YAML parser and emitter for Python" -groups = ["default"] -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - -[[package]] -name = "requests" -version = "2.31.0" -requires_python = ">=3.7" -summary = "Python HTTP for Humans." -groups = ["default"] -dependencies = [ - "certifi>=2017.4.17", - "charset-normalizer<4,>=2", - "idna<4,>=2.5", - "urllib3<3,>=1.21.1", -] -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[[package]] -name = "requests-oauthlib" -version = "1.3.1" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -summary = "OAuthlib authentication support for Requests." -groups = ["default"] -dependencies = [ - "oauthlib>=3.0.0", - "requests>=2.0.0", -] -files = [ - {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, - {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, -] - -[[package]] -name = "schematics" -version = "2.1.1" -summary = "Python Data Structures for Humans" -groups = ["default"] -files = [ - {file = "schematics-2.1.1-py2.py3-none-any.whl", hash = "sha256:be2d451bfb86789975e5ec0864aec569b63cea9010f0d24cbbd992a4e564c647"}, - {file = "schematics-2.1.1.tar.gz", hash = "sha256:34c87f51a25063bb498ae1cc201891b134cfcb329baf9e9f4f3ae869b767560f"}, -] - -[[package]] -name = "scikit-learn" -version = "1.2.2" -requires_python = ">=3.8" -summary = "A set of python modules for machine learning and data mining" -groups = ["default"] -dependencies = [ - "joblib>=1.1.1", - "numpy>=1.17.3", - "scipy>=1.3.2", - "threadpoolctl>=2.0.0", -] -files = [ - {file = "scikit-learn-1.2.2.tar.gz", hash = "sha256:8429aea30ec24e7a8c7ed8a3fa6213adf3814a6efbea09e16e0a0c71e1a1a3d7"}, - {file = "scikit_learn-1.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99cc01184e347de485bf253d19fcb3b1a3fb0ee4cea5ee3c43ec0cc429b6d29f"}, - {file = "scikit_learn-1.2.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e6e574db9914afcb4e11ade84fab084536a895ca60aadea3041e85b8ac963edb"}, - {file = "scikit_learn-1.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fe83b676f407f00afa388dd1fdd49e5c6612e551ed84f3b1b182858f09e987d"}, - {file = "scikit_learn-1.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2642baa0ad1e8f8188917423dd73994bf25429f8893ddbe115be3ca3183584"}, - {file = "scikit_learn-1.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ad66c3848c0a1ec13464b2a95d0a484fd5b02ce74268eaa7e0c697b904f31d6c"}, - {file = "scikit_learn-1.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfeaf8be72117eb61a164ea6fc8afb6dfe08c6f90365bde2dc16456e4bc8e45f"}, - {file = "scikit_learn-1.2.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:fe0aa1a7029ed3e1dcbf4a5bc675aa3b1bc468d9012ecf6c6f081251ca47f590"}, - {file = "scikit_learn-1.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:065e9673e24e0dc5113e2dd2b4ca30c9d8aa2fa90f4c0597241c93b63130d233"}, - {file = "scikit_learn-1.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf036ea7ef66115e0d49655f16febfa547886deba20149555a41d28f56fd6d3c"}, - {file = "scikit_learn-1.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8b0670d4224a3c2d596fd572fb4fa673b2a0ccfb07152688ebd2ea0b8c61025c"}, - {file = "scikit_learn-1.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8156db41e1c39c69aa2d8599ab7577af53e9e5e7a57b0504e116cc73c39138dd"}, - {file = "scikit_learn-1.2.2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fe175ee1dab589d2e1033657c5b6bec92a8a3b69103e3dd361b58014729975c3"}, - {file = "scikit_learn-1.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d5312d9674bed14f73773d2acf15a3272639b981e60b72c9b190a0cffed5bad"}, - {file = "scikit_learn-1.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea061bf0283bf9a9f36ea3c5d3231ba2176221bbd430abd2603b1c3b2ed85c89"}, - {file = "scikit_learn-1.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:6477eed40dbce190f9f9e9d0d37e020815825b300121307942ec2110302b66a3"}, -] - -[[package]] -name = "scipy" -version = "1.10.1" -requires_python = "<3.12,>=3.8" -summary = "Fundamental algorithms for scientific computing in Python" -groups = ["default"] -dependencies = [ - "numpy<1.27.0,>=1.19.5", -] -files = [ - {file = "scipy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7354fd7527a4b0377ce55f286805b34e8c54b91be865bac273f527e1b839019"}, - {file = "scipy-1.10.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:4b3f429188c66603a1a5c549fb414e4d3bdc2a24792e061ffbd607d3d75fd84e"}, - {file = "scipy-1.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1553b5dcddd64ba9a0d95355e63fe6c3fc303a8fd77c7bc91e77d61363f7433f"}, - {file = "scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c0ff64b06b10e35215abce517252b375e580a6125fd5fdf6421b98efbefb2d2"}, - {file = "scipy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:fae8a7b898c42dffe3f7361c40d5952b6bf32d10c4569098d276b4c547905ee1"}, - {file = "scipy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1564ea217e82c1bbe75ddf7285ba0709ecd503f048cb1236ae9995f64217bd"}, - {file = "scipy-1.10.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d925fa1c81b772882aa55bcc10bf88324dadb66ff85d548c71515f6689c6dac5"}, - {file = "scipy-1.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaea0a6be54462ec027de54fca511540980d1e9eea68b2d5c1dbfe084797be35"}, - {file = "scipy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15a35c4242ec5f292c3dd364a7c71a61be87a3d4ddcc693372813c0b73c9af1d"}, - {file = "scipy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:43b8e0bcb877faf0abfb613d51026cd5cc78918e9530e375727bf0625c82788f"}, - {file = "scipy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd9f1027ff30d90618914a64ca9b1a77a431159df0e2a195d8a9e8a04c78abf9"}, - {file = "scipy-1.10.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:79c8e5a6c6ffaf3a2262ef1be1e108a035cf4f05c14df56057b64acc5bebffb6"}, - {file = "scipy-1.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51af417a000d2dbe1ec6c372dfe688e041a7084da4fdd350aeb139bd3fb55353"}, - {file = "scipy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b4735d6c28aad3cdcf52117e0e91d6b39acd4272f3f5cd9907c24ee931ad601"}, - {file = "scipy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ff7f37b1bf4417baca958d254e8e2875d0cc23aaadbe65b3d5b3077b0eb23ea"}, - {file = "scipy-1.10.1.tar.gz", hash = "sha256:2cf9dfb80a7b4589ba4c40ce7588986d6d5cebc5457cad2c2880f6bc2d42f3a5"}, -] - -[[package]] -name = "sentry-sdk" -version = "1.26.0" -summary = "Python client for Sentry (https://sentry.io)" -groups = ["default"] -dependencies = [ - "certifi", - "urllib3>=1.25.7; python_version <= \"3.4\"", - "urllib3>=1.26.11; python_version >= \"3.6\"", - "urllib3>=1.26.9; python_version == \"3.5\"", -] -files = [ - {file = "sentry-sdk-1.26.0.tar.gz", hash = "sha256:760e4fb6d01c994110507133e08ecd4bdf4d75ee4be77f296a3579796cf73134"}, - {file = "sentry_sdk-1.26.0-py2.py3-none-any.whl", hash = "sha256:0c9f858337ec3781cf4851972ef42bba8c9828aea116b0dbed8f38c5f9a1896c"}, -] - -[[package]] -name = "sentry-sdk" -version = "1.26.0" -extras = ["flask"] -summary = "Python client for Sentry (https://sentry.io)" -groups = ["default"] -dependencies = [ - "blinker>=1.1", - "flask>=0.11", - "markupsafe", - "sentry-sdk==1.26.0", -] -files = [ - {file = "sentry-sdk-1.26.0.tar.gz", hash = "sha256:760e4fb6d01c994110507133e08ecd4bdf4d75ee4be77f296a3579796cf73134"}, - {file = "sentry_sdk-1.26.0-py2.py3-none-any.whl", hash = "sha256:0c9f858337ec3781cf4851972ef42bba8c9828aea116b0dbed8f38c5f9a1896c"}, -] - -[[package]] -name = "setuptools" -version = "67.7.2" -requires_python = ">=3.7" -summary = "Easily download, build, install, upgrade, and uninstall Python packages" -groups = ["default"] -files = [ - {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, - {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, -] - -[[package]] -name = "shapely" -version = "2.0.1" -requires_python = ">=3.7" -summary = "Manipulation and analysis of geometric objects" -groups = ["default"] -dependencies = [ - "numpy>=1.14", -] -files = [ - {file = "shapely-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b06d031bc64149e340448fea25eee01360a58936c89985cf584134171e05863f"}, - {file = "shapely-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9a6ac34c16f4d5d3c174c76c9d7614ec8fe735f8f82b6cc97a46b54f386a86bf"}, - {file = "shapely-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:865bc3d7cc0ea63189d11a0b1120d1307ed7a64720a8bfa5be2fde5fc6d0d33f"}, - {file = "shapely-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b4833235b90bc87ee26c6537438fa77559d994d2d3be5190dd2e54d31b2820"}, - {file = "shapely-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce88ec79df55430e37178a191ad8df45cae90b0f6972d46d867bf6ebbb58cc4d"}, - {file = "shapely-2.0.1-cp310-cp310-win32.whl", hash = "sha256:01224899ff692a62929ef1a3f5fe389043e262698a708ab7569f43a99a48ae82"}, - {file = "shapely-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:da71de5bf552d83dcc21b78cc0020e86f8d0feea43e202110973987ffa781c21"}, - {file = "shapely-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:502e0a607f1dcc6dee0125aeee886379be5242c854500ea5fd2e7ac076b9ce6d"}, - {file = "shapely-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7d3bbeefd8a6a1a1017265d2d36f8ff2d79d0162d8c141aa0d37a87063525656"}, - {file = "shapely-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f470a130d6ddb05b810fc1776d918659407f8d025b7f56d2742a596b6dffa6c7"}, - {file = "shapely-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4641325e065fd3e07d55677849c9ddfd0cf3ee98f96475126942e746d55b17c8"}, - {file = "shapely-2.0.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90cfa4144ff189a3c3de62e2f3669283c98fb760cfa2e82ff70df40f11cadb39"}, - {file = "shapely-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70a18fc7d6418e5aea76ac55dce33f98e75bd413c6eb39cfed6a1ba36469d7d4"}, - {file = "shapely-2.0.1-cp311-cp311-win32.whl", hash = "sha256:09d6c7763b1bee0d0a2b84bb32a4c25c6359ad1ac582a62d8b211e89de986154"}, - {file = "shapely-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d8f55f355be7821dade839df785a49dc9f16d1af363134d07eb11e9207e0b189"}, - {file = "shapely-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac1dfc397475d1de485e76de0c3c91cc9d79bd39012a84bb0f5e8a199fc17bef"}, - {file = "shapely-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33403b8896e1d98aaa3a52110d828b18985d740cc9f34f198922018b1e0f8afe"}, - {file = "shapely-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2569a4b91caeef54dd5ae9091ae6f63526d8ca0b376b5bb9fd1a3195d047d7d4"}, - {file = "shapely-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a70a614791ff65f5e283feed747e1cc3d9e6c6ba91556e640636bbb0a1e32a71"}, - {file = "shapely-2.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c43755d2c46b75a7b74ac6226d2cc9fa2a76c3263c5ae70c195c6fb4e7b08e79"}, - {file = "shapely-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad81f292fffbd568ae71828e6c387da7eb5384a79db9b4fde14dd9fdeffca9a"}, - {file = "shapely-2.0.1-cp39-cp39-win32.whl", hash = "sha256:b50c401b64883e61556a90b89948297f1714dbac29243d17ed9284a47e6dd731"}, - {file = "shapely-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bca57b683e3d94d0919e2f31e4d70fdfbb7059650ef1b431d9f4e045690edcd5"}, - {file = "shapely-2.0.1.tar.gz", hash = "sha256:66a6b1a3e72ece97fc85536a281476f9b7794de2e646ca8a4517e2e3c1446893"}, -] - -[[package]] -name = "six" -version = "1.16.0" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -summary = "Python 2 and 3 compatibility utilities" -groups = ["default"] -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.19" -requires_python = ">=3.7" -summary = "Database Abstraction Library" -groups = ["default"] -dependencies = [ - "greenlet!=0.4.17; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"", - "importlib-metadata; python_version < \"3.8\"", - "typing-extensions>=4.2.0", -] -files = [ - {file = "SQLAlchemy-2.0.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9deaae357edc2091a9ed5d25e9ee8bba98bcfae454b3911adeaf159c2e9ca9e3"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bf0fd65b50a330261ec7fe3d091dfc1c577483c96a9fa1e4323e932961aa1b5"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d90ccc15ba1baa345796a8fb1965223ca7ded2d235ccbef80a47b85cea2d71a"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4e688f6784427e5f9479d1a13617f573de8f7d4aa713ba82813bcd16e259d1"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:584f66e5e1979a7a00f4935015840be627e31ca29ad13f49a6e51e97a3fb8cae"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c69ce70047b801d2aba3e5ff3cba32014558966109fecab0c39d16c18510f15"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-win32.whl", hash = "sha256:96f0463573469579d32ad0c91929548d78314ef95c210a8115346271beeeaaa2"}, - {file = "SQLAlchemy-2.0.19-cp310-cp310-win_amd64.whl", hash = "sha256:22bafb1da60c24514c141a7ff852b52f9f573fb933b1e6b5263f0daa28ce6db9"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6894708eeb81f6d8193e996257223b6bb4041cb05a17cd5cf373ed836ef87a2"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8f2afd1aafded7362b397581772c670f20ea84d0a780b93a1a1529da7c3d369"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15afbf5aa76f2241184c1d3b61af1a72ba31ce4161013d7cb5c4c2fca04fd6e"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc05b59142445a4efb9c1fd75c334b431d35c304b0e33f4fa0ff1ea4890f92e"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5831138f0cc06b43edf5f99541c64adf0ab0d41f9a4471fd63b54ae18399e4de"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3afa8a21a9046917b3a12ffe016ba7ebe7a55a6fc0c7d950beb303c735c3c3ad"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-win32.whl", hash = "sha256:c896d4e6ab2eba2afa1d56be3d0b936c56d4666e789bfc59d6ae76e9fcf46145"}, - {file = "SQLAlchemy-2.0.19-cp311-cp311-win_amd64.whl", hash = "sha256:024d2f67fb3ec697555e48caeb7147cfe2c08065a4f1a52d93c3d44fc8e6ad1c"}, - {file = "SQLAlchemy-2.0.19-py3-none-any.whl", hash = "sha256:314145c1389b021a9ad5aa3a18bac6f5d939f9087d7fc5443be28cba19d2c972"}, - {file = "SQLAlchemy-2.0.19.tar.gz", hash = "sha256:77a14fa20264af73ddcdb1e2b9c5a829b8cc6b8304d0f093271980e36c200a3f"}, -] - -[[package]] -name = "text-unidecode" -version = "1.3" -summary = "The most basic Text::Unidecode port" -groups = ["default"] -files = [ - {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, - {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, -] - -[[package]] -name = "threadpoolctl" -version = "3.1.0" -requires_python = ">=3.6" -summary = "threadpoolctl" -groups = ["default"] -files = [ - {file = "threadpoolctl-3.1.0-py3-none-any.whl", hash = "sha256:8b99adda265feb6773280df41eece7b2e6561b772d21ffd52e372f999024907b"}, - {file = "threadpoolctl-3.1.0.tar.gz", hash = "sha256:a335baacfaa4400ae1f0d8e3a58d6674d2f8828e3716bb2802c44955ad391380"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -requires_python = ">=3.7" -summary = "A lil' TOML parser" -groups = ["lint", "test"] -marker = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.5.0" -requires_python = ">=3.7" -summary = "Backported and Experimental Type Hints for Python 3.7+" -groups = ["default", "lint"] -files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, -] - -[[package]] -name = "tzdata" -version = "2023.3" -requires_python = ">=2" -summary = "Provider of IANA time zone data" -groups = ["default"] -files = [ - {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, - {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, -] - -[[package]] -name = "tzlocal" -version = "5.0.1" -requires_python = ">=3.7" -summary = "tzinfo object for the local timezone" -groups = ["default"] -dependencies = [ - "backports-zoneinfo; python_version < \"3.9\"", - "tzdata; platform_system == \"Windows\"", -] -files = [ - {file = "tzlocal-5.0.1-py3-none-any.whl", hash = "sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f"}, - {file = "tzlocal-5.0.1.tar.gz", hash = "sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803"}, -] - -[[package]] -name = "urllib3" -version = "1.26.15" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["default"] -files = [ - {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, - {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -summary = "Character encoding aliases for legacy web content" -groups = ["default"] -files = [ - {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, - {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, -] - -[[package]] -name = "werkzeug" -version = "2.3.6" -requires_python = ">=3.8" -summary = "The comprehensive WSGI web application library." -groups = ["default"] -dependencies = [ - "MarkupSafe>=2.1.1", -] -files = [ - {file = "Werkzeug-2.3.6-py3-none-any.whl", hash = "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890"}, - {file = "Werkzeug-2.3.6.tar.gz", hash = "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330"}, -] - -[[package]] -name = "zipp" -version = "3.16.2" -requires_python = ">=3.8" -summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["default"] -files = [ - {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, - {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, -] - -[[package]] -name = "zope-event" -version = "4.6" -summary = "Very basic event publishing system" -groups = ["default"] -dependencies = [ - "setuptools", -] -files = [ - {file = "zope.event-4.6-py2.py3-none-any.whl", hash = "sha256:73d9e3ef750cca14816a9c322c7250b0d7c9dbc337df5d1b807ff8d3d0b9e97c"}, - {file = "zope.event-4.6.tar.gz", hash = "sha256:81d98813046fc86cc4136e3698fee628a3282f9c320db18658c21749235fce80"}, -] - -[[package]] -name = "zope-interface" -version = "6.0" -requires_python = ">=3.7" -summary = "Interfaces for Python" -groups = ["default"] -dependencies = [ - "setuptools", -] -files = [ - {file = "zope.interface-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f299c020c6679cb389814a3b81200fe55d428012c5e76da7e722491f5d205990"}, - {file = "zope.interface-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee4b43f35f5dc15e1fec55ccb53c130adb1d11e8ad8263d68b1284b66a04190d"}, - {file = "zope.interface-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a158846d0fca0a908c1afb281ddba88744d403f2550dc34405c3691769cdd85"}, - {file = "zope.interface-6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72f23bab1848edb7472309e9898603141644faec9fd57a823ea6b4d1c4c8995"}, - {file = "zope.interface-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48f4d38cf4b462e75fac78b6f11ad47b06b1c568eb59896db5b6ec1094eb467f"}, - {file = "zope.interface-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:87b690bbee9876163210fd3f500ee59f5803e4a6607d1b1238833b8885ebd410"}, - {file = "zope.interface-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2363e5fd81afb650085c6686f2ee3706975c54f331b426800b53531191fdf28"}, - {file = "zope.interface-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af169ba897692e9cd984a81cb0f02e46dacdc07d6cf9fd5c91e81f8efaf93d52"}, - {file = "zope.interface-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa90bac61c9dc3e1a563e5babb3fd2c0c1c80567e815442ddbe561eadc803b30"}, - {file = "zope.interface-6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89086c9d3490a0f265a3c4b794037a84541ff5ffa28bb9c24cc9f66566968464"}, - {file = "zope.interface-6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:809fe3bf1a91393abc7e92d607976bbb8586512913a79f2bf7d7ec15bd8ea518"}, - {file = "zope.interface-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ec9653825f837fbddc4e4b603d90269b501486c11800d7c761eee7ce46d1bbb"}, - {file = "zope.interface-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0583b75f2e70ec93f100931660328965bb9ff65ae54695fb3fa0a1255daa6f2"}, - {file = "zope.interface-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:23ac41d52fd15dd8be77e3257bc51bbb82469cf7f5e9a30b75e903e21439d16c"}, - {file = "zope.interface-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99856d6c98a326abbcc2363827e16bd6044f70f2ef42f453c0bd5440c4ce24e5"}, - {file = "zope.interface-6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1592f68ae11e557b9ff2bc96ac8fc30b187e77c45a3c9cd876e3368c53dc5ba8"}, - {file = "zope.interface-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4407b1435572e3e1610797c9203ad2753666c62883b921318c5403fb7139dec2"}, - {file = "zope.interface-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:5171eb073474a5038321409a630904fd61f12dd1856dd7e9d19cd6fe092cbbc5"}, - {file = "zope.interface-6.0.tar.gz", hash = "sha256:aab584725afd10c710b8f1e6e208dbee2d0ad009f57d674cb9d1b3964037275d"}, -] diff --git a/pyproject.toml b/pyproject.toml index 2ca43061e7..85a66e37ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,69 +1,73 @@ [project] -name = "HOTOSM Tasking Manager" +name = "TaskingManager" version = "0.1.0" description = "The tool to team up for mapping in OpenStreetMap" authors = [{ name = "HOT Sysadmin", email = "sysadmin@hotosm.org" }] +readme = "README.md" +license = { text = "BSD-2-Clause" } +requires-python = ">=3.9,<=3.11" dependencies = [ # Direct dependencies (at least one import requires it) "APScheduler==3.10.1", "alembic==1.11.1", + "asyncpg==0.29.0", "bleach==6.0.0", "cachetools==5.3.1", - "Flask==2.3.2", - "Flask-Cors==4.0.0", - "Flask-HTTPAuth==4.8.0", - "Flask-Migrate==4.0.4", - "Flask-Mail==0.9.1", - "Flask-RESTful==0.3.10", - "Flask-SQLAlchemy==3.0.5", - "flask-swagger==0.2.14", - "GeoAlchemy2==0.14.1", - "geojson==3.0.1", + "fastapi==0.108.0", + "GeoAlchemy2==0.14.3", + "geojson==3.1.0", "itsdangerous==2.1.2", + "loguru==0.7.2", "Markdown==3.4.4", + "numpy==1.26.4", "oauthlib==3.2.2", - "pandas>=2.0.2", - "psycopg2==2.9.6", + "pandas==2.2.2", + "pydantic==2.5.3", + "pydantic-settings==2.1.0", + "pyinstrument==4.6.2", "python-dateutil==2.8.2", "python-dotenv==1.0.0", "python-slugify==8.0.1", "requests==2.31.0", "requests-oauthlib==1.3.1", - "schematics==2.1.1", - "scikit-learn>=1.2.2", - "sentry-sdk[flask]==1.26.0", + "scikit-learn==1.4.2", + "sentry-sdk[fastapi]==1.26.0", "shapely==2.0.1", "SQLAlchemy==2.0.19", + "typing-extensions==4.8.0", + "uvicorn==0.19.0", "Werkzeug==2.3.6", # Indirect, but required dependencies (often required for efficient deployments) - "gevent==22.10.2", + "gevent==23.9.0", "greenlet==2.0.2", "gunicorn[gevent]==20.1.0", + "httptools>=0.6.4", + "uvloop>=0.21.0", # For importlib-metadata, see https://github.com/hotosm/tasking-manager/issues/5395 "importlib-metadata==6.8.0", # Dependencies used by hotosm.org for production deployments "newrelic==8.8.0", + "databases>=0.9.0", + "fastapi-mail==1.4.1", + "aiocache>=0.12.3", + "httpx>=0.28.1", + "pytest>=8.3.5", +] +[dependency-groups] +dev = [ + "pyinstrument>=4.6.2", + "debugpy==1.8.1" +] +lint = [ + "black>=25.1.0", + "flake8>=7.2.0", +] +test = [ + "coverage>=7.8.0", + "pytest>=8.3.5", ] -requires-python = ">=3.9,<=3.11" -readme = "README.md" -license = { text = "BSD-2-Clause" } [tool] -[tool.pdm.dev-dependencies] -test = ["coverage==7.2.7", "pytest==7.4.0"] -lint = ["black==23.7.0", "flake8==6.1.0"] -dev = ["psycopg2-binary>=2.9.6"] - -[tool.pdm.scripts] -start = "flask run --debug --reload" -migrate = "flask db migrate" -upgrade = "flask db upgrade" -downgrade = "flask db downgrade" -test = "python -m unittest discover" -lint = "black manage.py backend tests migrations" -flake8 = "flake8 manage.py backend tests migrations" -coverage-discover = "coverage run -m unittest discover" - [tool.commitizen] name = "cz_conventional_commits" @@ -72,6 +76,3 @@ version_scheme = "pep440" version_provider = "pep621" update_changelog_on_bump = true major_version_zero = true -[build-system] -requires = ["pdm-pep517>=1.0.0"] -build-backend = "pdm.pep517.api" diff --git a/requirements.txt b/requirements.txt index 5c1e335f86..20d1ea53e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,53 +1,39 @@ -# IMPORTANT: It is recommended to generate requirements.txt using "pdm export --dev --without-hashes > requirements.txt" -# Since we are using pdm for dependency management. - -# Update instructions: -# 1. Delete the virtual environment -# 2. Create a new clean virtual environment -# 3. Remove all unnecessary indirect dependencies in this file ("Indirect dependencies (these can be blown away at any time)") -# 4. Update the relevant packages -# 5. Run `pip install -r requirements.txt` -# 6. Run `pip freeze -r requirements.txt > requirements.new.txt` -# 7. Run `mv requirements.new.txt requirements.txt` -# 8. Run tests -# -# Direct dependencies (at least one import requires it) -APScheduler==3.10.1 alembic==1.11.1 +APScheduler==3.10.1 bleach==6.0.0 cachetools==5.3.1 -Flask==2.3.2 -Flask-Cors==4.0.0 -Flask-HTTPAuth==4.8.0 -Flask-Migrate==4.0.4 -Flask-Mail==0.9.1 -Flask-RESTful==0.3.10 -Flask-SQLAlchemy==3.0.5 -flask-swagger==0.2.14 -GeoAlchemy2==0.14.1 -geojson==3.0.1 +fastapi==0.108.0 +GeoAlchemy2==0.14.3 +geojson==3.1.0 itsdangerous==2.1.2 +loguru==0.7.2 Markdown==3.4.4 oauthlib==3.2.2 pandas>=2.0.2 -scikit-learn>=1.2.2 -psycopg2==2.9.6 +pydantic==2.5.3 +pydantic-settings==2.1.0 python-dateutil==2.8.2 python-dotenv==1.0.0 python-slugify==8.0.1 requests==2.31.0 requests-oauthlib==1.3.1 -schematics==2.1.1 -sentry-sdk[flask]==1.26.0 +scikit-learn>=1.2.2 shapely==2.0.1 SQLAlchemy==2.0.19 +typing-extensions==4.8.0 +uvicorn==0.19.0 Werkzeug==2.3.6 +asyncpg==0.29.0 +sqlmodel==0.0.16 + # Dev dependencies (stuff useful for development) black==23.7.0 coverage==7.2.7 flake8==6.1.0 psycopg2-binary>=2.9.6 pytest==7.4.0 +pyinstrument==4.6.2 + # Indirect, but required dependencies (often required for efficient deployments) gevent==22.10.2 greenlet==2.0.2 diff --git a/scripts/aws/cloudformation/tasking-manager.template.js b/scripts/aws/cloudformation/tasking-manager.template.js index 9c31ce2e0b..ec068efb3c 100644 --- a/scripts/aws/cloudformation/tasking-manager.template.js +++ b/scripts/aws/cloudformation/tasking-manager.template.js @@ -437,8 +437,8 @@ const Resources = { 'git clone --recursive https://github.com/hotosm/tasking-manager.git /opt/tasking-manager', 'cd /opt/tasking-manager/', cf.sub('git reset --hard ${GitSha}'), - 'pip install --upgrade pip pdm==2.18.1', - 'pdm export --prod > requirements.txt', + 'pip install --upgrade pip uv==0.7.2', + 'uv export --no-dev > requirements.txt', 'wget -6 https://s3.dualstack.us-east-1.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-py3-latest.tar.gz -O /tmp/aws-cfn-bootstrap-py3-latest.tar.gz', 'pip install /tmp/aws-cfn-bootstrap-py3-latest.tar.gz', 'pip install --user -r requirements.txt', diff --git a/scripts/aws/infra/README.md b/scripts/aws/infra/README.md new file mode 100644 index 0000000000..6b5384df9f --- /dev/null +++ b/scripts/aws/infra/README.md @@ -0,0 +1,202 @@ +## Tasking Manager Terraform/Terragrunt Setup + +This repository contains the infrastructure setup using Terraform with Terragrunt. It is structured to support multiple environments such as `dev`, `stag`, and others by reusing configurations and managing them efficiently. + +### Versions Used + +`terraform: hashicorp/terraform:1.9.5` +`terragrunt: terragrunt version v0.67.15` + +### Directory Structure + +The current directory structure is as follows: + +``` +. +├── Readme.md +├── _envcommon +│   ├── alb.hcl +│   ├── ecs.hcl +│   ├── extras.hcl +│   ├── rds.hcl +│   └── vpc.hcl +├── dev +│   ├── deployment_env.hcl +│   ├── non-purgeable +│   │   ├── alb +│   │   │   └── terragrunt.hcl +│   │   ├── extras +│   │   │   └── terragrunt.hcl +│   │   ├── rds +│   │   │   └── terragrunt.hcl +│   │   └── vpc +│   │   └── terragrunt.hcl +│   └── purgeable +│   └── ecs +│   └── terragrunt.hcl +├── terraform-aws-extras +│   ├── outputs.tf +│   ├── secrets.tf +│   ├── task-role.tf +│   └── variables.tf +└── root.hcl +``` + +### Key Components + +#### `_envcommon`: + +This directory contains shared configurations that are common across environments, such as configurations for the ALB, ECS, RDS, and VPC. These are used across different deployment environments. + +#### `dev/`: + +The dev environment contains environment-specific configuration files. +It includes: + +- `deployment_env.hcl`: Environment-specific variables for the dev environment. +- `non-purgeable/`: Contains resources that are not to be normally destroyed and are not expected to be updated often. Also have data related services, which are not expected to be purged. +- `purgeable/`: Contains resources that can be purged (e.g., ECS). + +#### `terraform-aws-extras/`: + +This is a module with specific `terraform` configurations like task roles, secrets, and outputs. It supplements the core terraform modules that are specific to `tasking-manager`. + +## Managing Environments + +### To create a new environment (e.g., stag): + +- Copy the dev directory and rename it to stag. +- Adjust `deployment_env.hcl` in the new environment to reflect environment-specific values. +- Update any relevant configurations for environment-specific resources. + +### Running Terraform with Terragrunt + +Install Terragrunt and Terraform if you haven’t already. +You can install Terragrunt via the following commands: + +```bash +brew install terragrunt # On macOS +``` + +### Environments and Inputs + +Depending on the setup the inputs and vars that needs to be configured are: + +#### 1. Container non-sensative varibles + +These can be configured by appending `container_envvars` located `infra//purgeable/ecs/terragrunt.hcl`. +As an option for CI, their values can be overridden from env variables too. + +Example: + +```conf +EXTRA_CORS_ORIGINS = get_env("EXTRA_CORS_ORIGINS" ,"[\"https://tasks-stage.hotosm.org\", \"http://localhost:3000\"]") +TM_SMTP_HOST = get_env("TM_SMTP_HOST" ,"smtp.gmail.com") +TM_SMTP_PORT = get_env("TM_SMTP_PORT" ,"587") +TM_SMTP_USE_TLS = get_env("TM_SMTP_USE_TLS" ,"0") +TM_SMTP_USE_SSL = get_env("TM_SMTP_USE_SSL" ,"1") +TM_APP_BASE_URL = get_env("TM_APP_BASE_URL" ,"https://tasks-stage.hotosm.org") +``` + +A. Overriding the hardcoded environment. + +> The env exported here is for application. It is exported as secrets in secret manager or hardcoded environment into task definition. + +```bash +$ cp example.env tasking-manager.env +$ nano tasking-manager.env +$ export $(grep -v '#' tasking-manager.env | xargs) +``` + +#### 2. Container Secrets + +These can be configured to automatically be stored within AWS Secrets Manager. +Since these vary depending on deployment environment, you can find them at `infra//non-purgeable/extras/terragrunt.hcl` +It's not recommended to have them here, so you will need to export them using environment variable. + +Write TM required environment files. Export environment variables for container secrets. +The sample env files is located in `scripts/aws/infra/infra.env.example`. Edit the env file and export them for infra deployment. + +A. Copy the example environment file to a new file: + +```bash +$ cp infra.env.example infra.env +``` + +B. Edit the file to configure your environment variables: + +```bash +$ nano infra.env # or use vi, vim, etc. +``` + +C. Export the variables for the infra: + +```bash +$ export $(grep -v '#' infra.env | xargs) +``` + +The secret section is sent to `infra//non-purgeable/extras/terragrunt.hcl`. These secret are configured can be configured to automatically be stored within AWS Secrets Manager. + +```conf +container_secrets = [ + { + name = "TM_SECRET" + valueFrom = get_env("TM_SECRET", "default_secret_value") + }, + { + name = "TM_CLIENT_ID" + valueFrom = get_env("TM_CLIENT_ID", "default_client_id") + } +] +``` + +#### 3. Terragrunt Inputs + +Just like variables and secrets the input ALB-ACM-CERT-BACKEND-ARN can be taken from the environment or edited inline. + +These are inputs that different resources will need. Ideally, everything is passed within terraform modules using outputs, but in cases of resources that already exists eg: `SSL certificates`, `IAM role` etc they will need to be passed manually. + +- ALB Certificate ARN: + +```json +# tasking-manager/scripts/aws/infra/dev/non-purgeable/alb/terragrunt.hcl + +inputs = { + # TLS and Certificate Configuration + acm_tls_cert_backend_arn = get_env("ACM_TLS_CERT_BACKEND_ARN", "arn:aws:acm:us-east-2:123456789:certificate/810d8829-5e61-arn-cert-example") +} +``` + +### Terragrunt plan & apply + +To deploy resources individually, navigate to the resource and run: + +```bash +# Example for creating VPC resource for developement environment. +cd scripts/aws/infra/dev/non-purgeable/vpc +terragrunt plan +terragrunt apply + +# Example for creating RDS instance for development environment. +cd scripts/aws/infra/dev/non-purgeable/rds +terragrunt plan +terragrunt apply +``` + +To deploy all resources in dependency order, navigate to the desired environment directory (dev or stag) and run: + +```bash +cd scripts/aws/infra/ +terragrunt init +terragrunt plan +terragrunt run-all apply +``` + +### Destroying Resources with Terragrunt + +To destroy the RDS instance that you created for development environment, navigate to the same directory and run: + +```bash +cd scripts/aws/infra/dev/non-purgeable/rds +terragrunt destroy +``` diff --git a/scripts/aws/infra/_envcommon/alb.hcl b/scripts/aws/infra/_envcommon/alb.hcl new file mode 100644 index 0000000000..1a1f7379ae --- /dev/null +++ b/scripts/aws/infra/_envcommon/alb.hcl @@ -0,0 +1,46 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-alb/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + app_port = "5000" #TODOTM + health_check_path = "/api/v2/system/heartbeat/" #TODOTM + alb_name = format("%s-%s-%s", local.application, local.environment, "alb") + target_group_name = format("%s-%s-%s", local.application, local.environment, "tg") +} diff --git a/scripts/aws/infra/_envcommon/cloudfront.hcl b/scripts/aws/infra/_envcommon/cloudfront.hcl new file mode 100644 index 0000000000..1d4a3daa17 --- /dev/null +++ b/scripts/aws/infra/_envcommon/cloudfront.hcl @@ -0,0 +1,44 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=master" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + distribution_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "cloudfront") + + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-cloudfront/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { +} diff --git a/scripts/aws/infra/_envcommon/ec2.hcl b/scripts/aws/infra/_envcommon/ec2.hcl new file mode 100644 index 0000000000..8020fb68d1 --- /dev/null +++ b/scripts/aws/infra/_envcommon/ec2.hcl @@ -0,0 +1,46 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-ec2/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + project_meta = { + name = local.environment_vars.locals.project + environment = local.environment_vars.locals.environment + team = local.environment_vars.locals.team + } +} diff --git a/scripts/aws/infra/_envcommon/ecs-cron.hcl b/scripts/aws/infra/_envcommon/ecs-cron.hcl new file mode 100644 index 0000000000..9ec1f2dd5d --- /dev/null +++ b/scripts/aws/infra/_envcommon/ecs-cron.hcl @@ -0,0 +1,64 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-ecs/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + service_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "backend") + + log_configuration = { + logdriver = "awslogs" + options = { + awslogs-group = format("%s-%s-%s-%s", local.application, local.team, local.environment, "cron") + awslogs-region = local.environment_vars.locals.aws_region + awslogs-stream-prefix = "cron" + } + } + + container_settings = { + app_port = 80 + cpu_architecture = "X86_64" + image_url = "ghcr.io/hotosm/tasking-manager/backend" + image_tag = "fastapi" + service_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "cron") + } + + container_capacity = { + cpu = 2048 + memory_mb = 4096 + } +} diff --git a/scripts/aws/infra/_envcommon/ecs.hcl b/scripts/aws/infra/_envcommon/ecs.hcl new file mode 100644 index 0000000000..7ad85cb39c --- /dev/null +++ b/scripts/aws/infra/_envcommon/ecs.hcl @@ -0,0 +1,73 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-ecs/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + service_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "backend") + + log_configuration = { + logdriver = "awslogs" + options = { + awslogs-group = format("%s-%s-%s-%s", local.application, local.team, local.environment, "fastapi") + awslogs-region = local.environment_vars.locals.aws_region + awslogs-stream-prefix = "api" + } + } + + efs_settings = { + file_system_id = "" + access_point_id = "" + root_directory = "/" + transit_encryption = "ENABLED" + iam_authz = "DISABLED" + } + + container_settings = { + app_port = 80 + cpu_architecture = "X86_64" + image_url = "ghcr.io/hotosm/tasking-manager/backend" + image_tag = local.environment + service_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "fastapi") + } + + ## Default tested resources needed for fastapi container. Override using ..//purgeable/ecs/terragrunt.hcl + container_capacity = { + cpu = 2048 + memory_mb = 4096 + } +} diff --git a/scripts/aws/infra/_envcommon/extras.hcl b/scripts/aws/infra/_envcommon/extras.hcl new file mode 100644 index 0000000000..7a4efdecdc --- /dev/null +++ b/scripts/aws/infra/_envcommon/extras.hcl @@ -0,0 +1,37 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "${get_path_to_repo_root()}/scripts/aws/infra//terraform-aws-extras" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + deployment_environment = local.environment_vars.locals.environment +} diff --git a/scripts/aws/infra/_envcommon/rds.hcl b/scripts/aws/infra/_envcommon/rds.hcl new file mode 100644 index 0000000000..c514bc9afa --- /dev/null +++ b/scripts/aws/infra/_envcommon/rds.hcl @@ -0,0 +1,70 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-rds/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + + project_meta = { + name = local.environment_vars.locals.project + short_name = local.environment_vars.locals.short_name + team = local.environment_vars.locals.team + version = local.environment_vars.locals.version + url = local.environment_vars.locals.url + } + + org_meta = { + name = local.environment_vars.locals.project + short_name = local.environment_vars.locals.short_name + url = local.environment_vars.locals.url + } + + deployment_environment = local.environment_vars.locals.environment + deletion_protection = true + // default_tags = var.default_tags + + database = { + name = join("_", [ + local.environment_vars.locals.short_name, + local.environment_vars.locals.environment, + ]) + admin_user = join("_", [ + local.environment_vars.locals.short_name, + local.environment_vars.locals.environment, + ]) + password_length = 48 + engine_version = 15 + port = 5432 + } + + network_type = "IPV4" +} diff --git a/scripts/aws/infra/_envcommon/s3.hcl b/scripts/aws/infra/_envcommon/s3.hcl new file mode 100644 index 0000000000..6ac4ebe556 --- /dev/null +++ b/scripts/aws/infra/_envcommon/s3.hcl @@ -0,0 +1,45 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=main" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract out common variables for reuse + environment = local.environment_vars.locals.environment + application = local.environment_vars.locals.application + team = local.environment_vars.locals.team + + bucket_name = format("%s-%s-%s-%s", local.application, local.team, local.environment, "frontend-static") + + + # Expose the base source URL so different versions of the module can be deployed in different environments. This will + # be used to construct the terraform block in the child terragrunt configurations. + base_source_url = "git::https://github.com/hotosm/terraform-aws-s3/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + bucket = local.bucket_name +} diff --git a/scripts/aws/infra/_envcommon/vpc.hcl b/scripts/aws/infra/_envcommon/vpc.hcl new file mode 100644 index 0000000000..0b59ec6323 --- /dev/null +++ b/scripts/aws/infra/_envcommon/vpc.hcl @@ -0,0 +1,41 @@ +# --------------------------------------------------------------------------------------------------------------------- +# COMMON TERRAGRUNT CONFIGURATION +# This is the common component configuration for mysql. The common variables for each environment to +# deploy mysql are defined here. This configuration will be merged into the environment configuration +# via an include block. +# --------------------------------------------------------------------------------------------------------------------- + +# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the +# working directory, into a temporary folder, and execute your Terraform commands in that folder. If any environment +# needs to deploy a different module version, it should redefine this block with a different ref to override the +# deployed version. +terraform { + source = "${local.base_source_url}?ref=tasking-manager-infra" +} + +# --------------------------------------------------------------------------------------------------------------------- +# Locals are named constants that are reusable within the configuration. +# --------------------------------------------------------------------------------------------------------------------- +locals { + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + base_source_url = "git::https://github.com/hotosm/terraform-aws-vpc/" +} + +# --------------------------------------------------------------------------------------------------------------------- +# MODULE PARAMETERS +# These are the variables we have to pass in to use the module. This defines the parameters that are common across all +# environments. +# --------------------------------------------------------------------------------------------------------------------- +# Defaults, overridden by env.hcl + +inputs = { + project_meta = { + name = local.environment_vars.locals.project + short_name = local.environment_vars.locals.short_name + team = local.environment_vars.locals.team + version = local.environment_vars.locals.version + url = local.environment_vars.locals.url + } + + deployment_environment = local.environment_vars.locals.environment +} diff --git a/scripts/aws/infra/develop/deployment_env.hcl b/scripts/aws/infra/develop/deployment_env.hcl new file mode 100644 index 0000000000..b408f1be99 --- /dev/null +++ b/scripts/aws/infra/develop/deployment_env.hcl @@ -0,0 +1,17 @@ +locals { + account_name = get_env("AWS_ACCOUNT_NAME", "hotosm") + aws_profile = get_env("AWS_PROFILE", "default") + aws_region = get_env("AWS_REGION", "us-east-1") + team = get_env("INFRA_TEAM", "hotosm") + owner = get_env("INFRA_OWNER", "HOTOSM") + environment = get_env("INFRA_ENVIRONMENT", "develop") + project = get_env("INFRA_PROJECT", "tasking-manager") + application = get_env("INFRA_APPLICATION", "tasking-manager") + short_name = get_env("INFRA_PROJECT_SHORT_NAME", "tm") + maintainer = get_env("INFRA_MAINTAINER", "dev@hotosm.org") + url = get_env("INFRA_URL", "https://tasks-dev.hotosm.org") + documentation = get_env("INFRA_DOCS_URL", "https://hotosm.github.io") + IaC_Management = get_env("INFRA_IAC_MANAGER", "Terraform/Terragrunt") + cost_center = get_env("INFRA_ENABLE_COST_CENTER", "False") + version = get_env("INFRA_APP_VERSION", "4.8.2") +} diff --git a/scripts/aws/infra/develop/non-purgeable/alb/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/alb/terragrunt.hcl new file mode 100644 index 0000000000..b7da7c1821 --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/alb/terragrunt.hcl @@ -0,0 +1,26 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/alb.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.0" +} + +dependency "vpc" { + config_path = "../vpc" +} + +inputs = { + # General VPC Configuration + vpc_id = dependency.vpc.outputs.vpc_id + alb_subnets = dependency.vpc.outputs.public_subnets + + # TLS and Certificate Configuration + acm_tls_cert_backend_arn = get_env("ACM_TLS_CERT_BACKEND_ARN", "arn:aws:acm:us-east-1:670261699094:certificate/1d74321b-1e5b-4e31-b97a-580deb39c539") +} diff --git a/scripts/aws/infra/develop/non-purgeable/cloudfront/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/cloudfront/terragrunt.hcl new file mode 100644 index 0000000000..e5263145ea --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/cloudfront/terragrunt.hcl @@ -0,0 +1,72 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/cloudfront.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.1.0" +} + +dependency "s3" { + config_path = "../s3" +} + +inputs = { + # S3 bucket configuration - only need to provide the bucket name + s3_bucket_name = dependency.s3.outputs.bucket_name + create_s3_bucket_policy = true + + aliases = split(" ", get_env("CLOUDFRONT_DIST_ALIASES", "tasks-dev.hotosm.org")) + + # CloudFront configuration + enabled = true + is_ipv6_enabled = true + comment = "Frontend Static distribution for ${include.envcommon.locals.distribution_name}" + default_root_object = "index.html" + + # Price class - choose one of: [ + # - PriceClass_100 (US, Canada, Europe) + # - PriceClass_200 (PriceClass_100 + South America, Australia, New Zealand, and parts of Asia) + # - PriceClass_All (All regions) + # ] + + # Cache behavior + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + forward_query_string = false + forward_cookies = "none" + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + compress = true + + # Geo restrictions + geo_restriction_type = "none" + geo_restriction_locations = [] + + custom_error_response_pages = [ + { + error_code = 404 + response_page_path = "/index.html" + response_code = 404 + error_caching_min_ttl = 60 + }, + { + error_code = 403 + response_page_path = "/index.html" + response_code = 403 + error_caching_min_ttl = 60 + } + ] + + # SSL/TLS configuration + use_default_certificate = false + # If use_default_certificate is false, provide these: + acm_certificate_arn = get_env("ACM_TLS_CERT_FRONTEND_ARN", "arn:aws:acm:us-east-1:670261699094:certificate/1d74321b-1e5b-4e31-b97a-580deb39c539") +} diff --git a/scripts/aws/infra/develop/non-purgeable/extras/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/extras/terragrunt.hcl new file mode 100644 index 0000000000..d6c7a4104d --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/extras/terragrunt.hcl @@ -0,0 +1,61 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/extras.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}" +} + +inputs = { + ## Override them by exporting the vars to the environment. + ## Example: + ## export TM_SECRET=`openssl rand -hex 32` + container_secrets = [ + { + name = "TM_SECRET" + valueFrom = get_env("TM_SECRET", "default_secret_value") + }, + { + name = "TM_CLIENT_ID" + valueFrom = get_env("TM_CLIENT_ID", "default_client_id") + }, + { + name = "TM_CLIENT_SECRET" + valueFrom = get_env("TM_CLIENT_SECRET", "default_client_secret") + }, + { + name = "OHSOME_STATS_TOKEN" + valueFrom = get_env("OHSOME_STATS_TOKEN", "default_ohsome_stats_token") + }, + { + name = "TM_SMTP_USER" + valueFrom = get_env("TM_SMTP_USER", "default_smtp_user") + }, + { + name = "TM_SMTP_PASSWORD" + valueFrom = get_env("TM_SMTP_PASSWORD", "default_smtp_password") + }, + { + name = "TM_SENTRY_BACKEND_DSN" + valueFrom = get_env("TM_SENTRY_BACKEND_DSN", "https://sentryyourpublickey@o68147.ingest.sentry.io/projectid") + }, + { + name = "TM_IMAGE_UPLOAD_API_URL" + valueFrom = get_env("TM_IMAGE_UPLOAD_API_URL", "https://somethinglikethis.execute-api.us-east-1.amazonaws.com/environment/upload") + }, + { + name = "TM_IMAGE_UPLOAD_API_KEY" + valueFrom = get_env("TM_IMAGE_UPLOAD_API_KEY", "keytoimageuploadapiurl") + }, + { + name = "NEW_RELIC_LICENSE_KEY" + valueFrom = get_env("NEW_RELIC_LICENSE_KEY", "newrelicliscencekey") + } + ] +} diff --git a/scripts/aws/infra/develop/non-purgeable/rds/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/rds/terragrunt.hcl new file mode 100644 index 0000000000..95ebe20327 --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/rds/terragrunt.hcl @@ -0,0 +1,40 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/rds.hcl" + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.0" +} + +dependency "vpc" { + config_path = "../vpc" +} + +# Add in any new inputs that you want to overide. +inputs = { + ## VPC Inputs for RDS Instance + vpc_id = dependency.vpc.outputs.vpc_id + subnet_ids = dependency.vpc.outputs.private_subnets + + ## RDS Module inputs + serverless_capacity = { + minimum = 1 # Lowest possible APU for Aurora Serverless + maximum = 4 # Max APU to keep cost low for Stag + } + + ## RDS Backup/Snapshot Config + backup = { + retention_days = 7 + skip_final_snapshot = true + final_snapshot_identifier = "final" + } + + # RDS Dev Deployment only. + public_access = true + deletion_protection = true +} diff --git a/scripts/aws/infra/develop/non-purgeable/s3/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/s3/terragrunt.hcl new file mode 100644 index 0000000000..a855816945 --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/s3/terragrunt.hcl @@ -0,0 +1,18 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/s3.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.0.0" +} + +inputs = { + attach_public_policy = false + attach_policy = false +} diff --git a/scripts/aws/infra/develop/non-purgeable/vpc/terragrunt.hcl b/scripts/aws/infra/develop/non-purgeable/vpc/terragrunt.hcl new file mode 100644 index 0000000000..d282de86d0 --- /dev/null +++ b/scripts/aws/infra/develop/non-purgeable/vpc/terragrunt.hcl @@ -0,0 +1,35 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/vpc.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=oo-v1.0" +} + +## Modify inputs for overriding _envcommon's inputs. + +inputs = { + vpc_id = "vpc-08ecfc1c7844c7c5a" + private_subnets = [ + "subnet-05aa252699783b4cf", + "subnet-0a75cddfef3213c51", + "subnet-0a8b06831b3de5f66", + "subnet-0f76ca222b0544a40", + "subnet-03919b5e26cba5733", + "subnet-0b91332acbe8b1a4c", + ] + public_subnets = [ + "subnet-0be484781db027eaf", + "subnet-08d4ebd1a6c272fa2", + "subnet-0807976e2acba8301", + "subnet-08448f588d40c002e", + "subnet-0a5597ba3564eef97", + "subnet-0176ed40bffff6728", + ] +} diff --git a/scripts/aws/infra/develop/purgeable/common-ecs-env.hcl b/scripts/aws/infra/develop/purgeable/common-ecs-env.hcl new file mode 100644 index 0000000000..af2de143be --- /dev/null +++ b/scripts/aws/infra/develop/purgeable/common-ecs-env.hcl @@ -0,0 +1,49 @@ +locals { + envs = { + EXTRA_CORS_ORIGINS = get_env("EXTRA_CORS_ORIGINS", "[\"https://hotosm.github.io\", \"https://tasks-dev.hotosm.org\", \"http://localhost:3000\"]") + TM_SMTP_HOST = get_env("TM_SMTP_HOST", "email-smtp.us-east-1.amazonaws.com") + TM_SMTP_PORT = get_env("TM_SMTP_PORT", "587") + TM_SMTP_USE_TLS = get_env("TM_SMTP_USE_TLS", "1") + TM_SMTP_USE_SSL = get_env("TM_SMTP_USE_SSL", "0") + TM_EMAIL_FROM_ADDRESS = get_env("TM_EMAIL_FROM_ADDRESS", "noreply@hotosmmail.org") + TM_EMAIL_CONTACT_ADDRESS = get_env("TM_EMAIL_CONTACT_ADDRESS", "sysadmin@hotosm.org") + TM_APP_BASE_URL = get_env("TM_APP_BASE_URL", "https://tasks-dev.hotosm.org") + TM_APP_API_URL = get_env("TM_APP_API_URL", "https://tasking-manager-dev-api.hotosm.org/api") + TM_REDIRECT_URI = get_env("TM_REDIRECT_URI", "https://tasks-dev.hotosm.org/authorized") + TM_APP_API_VERSION = get_env("TM_APP_API_VERSION", "v2") + TM_ORG_NAME = get_env("TM_ORG_NAME", "Humanitarian OpenStreetMap Team") + TM_ORG_CODE = get_env("TM_ORG_CODE", "HOT") + TM_ORG_LOGO = get_env("TM_ORG_LOGO", "https://cdn.hotosm.org/tasking-manager/uploads/1588741335578_hot-logo.png") + TM_ORG_URL = get_env("TM_ORG_URL", "https://www.hotosm.org/") + TM_ORG_PRIVACY_POLICY_URL = get_env("TM_ORG_PRIVACY_POLICY_URL", "https://www.hotosm.org/privacy") + TM_ORG_TWITTER = get_env("TM_ORG_TWITTER", "http://twitter.com/hotosm") + TM_ORG_FB = get_env("TM_ORG_FB", "https://www.facebook.com/hotosm") + TM_ORG_INSTAGRAM = get_env("TM_ORG_INSTAGRAM", "https://www.instagram.com/open.mapping.hubs/") + TM_ORG_YOUTUBE = get_env("TM_ORG_YOUTUBE", "https://www.youtube.com/user/hotosm") + TM_ORG_GITHUB = get_env("TM_ORG_GITHUB", "https://github.com/hotosm") + OSM_SERVER_URL = get_env("OSM_SERVER_URL", "https://www.openstreetmap.org") + OSM_SERVER_API_URL = get_env("OSM_SERVER_API_URL", "https://api.openstreetmap.org") + OSM_NOMINATIM_SERVER_URL = get_env("OSM_NOMINATIM_SERVER_URL", "https://nominatim.openstreetmap.org") + OSM_REGISTER_URL = get_env("OSM_REGISTER_URL", "https://www.openstreetmap.org/user/new") + POSTGRES_TEST_DB = get_env("POSTGRES_TEST_DB", "tasking-manager-test") + TM_SEND_PROJECT_EMAIL_UPDATES = get_env("TM_SEND_PROJECT_EMAIL_UPDATES", "1") + TM_DEFAULT_LOCALE = get_env("TM_DEFAULT_LOCALE", "en") + TM_LOG_LEVEL = get_env("TM_LOG_LEVEL", "10") + TM_LOG_DIR = get_env("TM_LOG_DIR", "/var/log/tasking-manager-logs") + TM_SUPPORTED_LANGUAGES_CODES = get_env("TM_SUPPORTED_LANGUAGES_CODES", "ar, cs, de, el, en, es, fa_IR, fr, he, hu, id, it, ja, ko, mg, ml, nl_NL, pt, pt_BR, ru, sv, sw, tl, tr, uk, zh_TW") + TM_SUPPORTED_LANGUAGES = get_env("TM_SUPPORTED_LANGUAGES", "عربى, Čeština, Deutsch, Ελληνικά, English, Español, فارسی, Français, עברית, Magyar, Indonesia, Italiano, 日本語, 한국어, Malagasy, Malayalam, Nederlands, Português, Português (Brasil), Русский язык, Svenska, Kiswahili, Filipino (Tagalog), Türkçe, Українська, 繁體中文") + TM_DEFAULT_CHANGESET_COMMENT = get_env("TM_DEFAULT_CHANGESET_COMMENT", "#hot-tm-stage-project") + TM_ENVIRONMENT = get_env("TM_ENVIRONMENT", "tasking-manager-develop") + NEW_RELIC_ENVIRONMENT = get_env("NEW_RELIC_ENVIRONMENT", "tasking-manager-develop") + NEW_RELIC_CONFIG_FILE = get_env("NEW_RELIC_CONFIG_FILE", "./scripts/aws/cloudformation/newrelic.ini") + USE_SENTRY = get_env("USE_SENTRY", "true") + # Uncomment the following as needed. + # TM_TASK_AUTOUNLOCK_AFTER = get_env("TM_TASK_AUTOUNLOCK_AFTER", "2h") + # TM_MAPPER_LEVEL_INTERMEDIATE = get_env("TM_MAPPER_LEVEL_INTERMEDIATE", "250") + # TM_MAPPER_LEVEL_ADVANCED = get_env("TM_MAPPER_LEVEL_ADVANCED", "500") + # TM_IMPORT_MAX_FILESIZE = get_env("TM_IMPORT_MAX_FILESIZE", "1000000") + # TM_MAX_AOI_AREA = get_env("TM_MAX_AOI_AREA", "5000") + # EXPORT_TOOL_S3_URL = get_env("EXPORT_TOOL_S3_URL", "https://foorawdataapi.s3.amazonaws.com") + # ENABLE_EXPORT_TOOL = get_env("ENABLE_EXPORT_TOOL", "1") + } +} diff --git a/scripts/aws/infra/develop/purgeable/ecs-cron/terragrunt.hcl b/scripts/aws/infra/develop/purgeable/ecs-cron/terragrunt.hcl new file mode 100644 index 0000000000..af3aff7dfc --- /dev/null +++ b/scripts/aws/infra/develop/purgeable/ecs-cron/terragrunt.hcl @@ -0,0 +1,95 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# This is the configuration for Terragrunt, a thin wrapper for Terraform and OpenTofu that helps keep your code DRY and +# maintainable: https://github.com/gruntwork-io/terragrunt +# --------------------------------------------------------------------------------------------------------------------- + +# Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all +# components and environments, such as how to configure remote state. +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include the envcommon configuration for the component. The envcommon configuration contains settings that are common +# for the component across all environments. +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/ecs-cron.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +# Configure the version of the module to use in this environment. This allows you to promote new versions one +# environment at a time (e.g., qa -> stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "python3 backend/cron_jobs.py" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 100 + max_pct = 200 + } + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 1 + container_max_count = 1 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) +} diff --git a/scripts/aws/infra/develop/purgeable/ecs/terragrunt.hcl b/scripts/aws/infra/develop/purgeable/ecs/terragrunt.hcl new file mode 100644 index 0000000000..a34032ebc2 --- /dev/null +++ b/scripts/aws/infra/develop/purgeable/ecs/terragrunt.hcl @@ -0,0 +1,152 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# This is the configuration for Terragrunt, a thin wrapper for Terraform and OpenTofu that helps keep your code DRY and +# maintainable: https://github.com/gruntwork-io/terragrunt +# --------------------------------------------------------------------------------------------------------------------- + +# Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all +# components and environments, such as how to configure remote state. +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include the envcommon configuration for the component. The envcommon configuration contains settings that are common +# for the component across all environments. +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/ecs.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +# Configure the version of the module to use in this environment. This allows you to promote new versions one +# environment at a time (e.g., qa -> stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + load_balancer_settings = { + enabled = true + target_group_arn = dependency.alb.outputs.target_group_arn + target_group_arn_suffix = dependency.alb.outputs.target_group_arn_suffix + arn_suffix = dependency.alb.outputs.load_balancer_arn_suffix + scaling_request_count = 200 + } + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "alembic -c migrations/alembic.ini upgrade head && uvicorn backend.main:api --host 0.0.0.0 --port 5000 --workers 8 --log-level critical --no-access-log" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 25 + max_pct = 200 + } + + health_check_grace_period_seconds = 60 + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 1 + container_max_count = 3 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) + + # terraform.tfvars + + cpu_scaling_config = { + enabled = true + threshold = 60 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = null, adjustment = 2 } + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } + + memory_scaling_config = { + enabled = true + threshold = 70 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = null, adjustment = 2 }, + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } + + request_scaling_config = { + enabled = true + threshold = 100 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 100, adjustment = 1 }, + { lower_bound = 100, upper_bound = null, adjustment = 2 }, + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } +} diff --git a/scripts/aws/infra/infra.env.example b/scripts/aws/infra/infra.env.example new file mode 100644 index 0000000000..0bcecbadf5 --- /dev/null +++ b/scripts/aws/infra/infra.env.example @@ -0,0 +1,27 @@ +# ================ GHActions-START ================ +# Environment required for CICD pipeline. Env here is not used for app but rather Infra deployment via CI or manual run. + +# ==== VARIABLES-START ==== +# Env-Variables (Redundant per environment) START +IMAGE_NAME=hotosm/tasking-manager-backend # [optional] image name for CI +FRONTEND_S3_BUCKET="your-s3-bucket-name" # [mandatory] frontend bucket name for CI +FRONTEND_CLOUDFRONT_DISTRIBUTION_ID='EXxxxxxxxxx' # [mandatory] your cloudfront distribution id for CI +# Env-Variables (Redundant per environment) END + +# ==== VARIABLES-END ==== + +# ==== SECRETS ==== +# Same secret as TM_SECRET but to encrypt terragrunt plan file uploaded to github artifacts +AWS_OIDC_ROLE_ARN=arn:aws:iam::123456789012:role/YOUR_ROLE_NAME +PLAN_FILE_ENCRYPTION_SECRET=UNSAFESTRINGUSEDUSECUSTOMSECRETKEY +# ==== SECRETS-END ==== + +# ================ GHActions-END ================ + +# ================ INFRA-START ================ +# Environment here are for infra application via CI +INFRA_TEAM=hotosm # [optional] if you want to override the team name +ACM_TLS_CERT_FRONTEND_ARN=arn:aws:acm:EU-east-66:123456789:certificate/ARN_EXAMPLE # [optional] ACM certificate for FRONTEND +ACM_TLS_CERT_BACKEND_ARN=arn:aws:acm:EU-east-66:123456789:certificate/ARN_EXAMPLE # [optional] ACM certificate for BACKEND +CLOUDFRONT_DIST_ALIASES="tasks.example.com more.example.com more2.example.com" # [optional] hostname for cloudfront. Your ACM should support it else fails. +# ================ INFRA-END ================ diff --git a/scripts/aws/infra/production/deployment_env.hcl b/scripts/aws/infra/production/deployment_env.hcl new file mode 100644 index 0000000000..140b37e715 --- /dev/null +++ b/scripts/aws/infra/production/deployment_env.hcl @@ -0,0 +1,17 @@ +locals { + account_name = get_env("AWS_ACCOUNT_NAME", "hotosm") + aws_profile = get_env("AWS_PROFILE", "default") + aws_region = get_env("AWS_REGION", "us-east-1") + team = get_env("INFRA_TEAM", "hotosm") + owner = get_env("INFRA_OWNER", "HOTOSM") + environment = get_env("INFRA_ENVIRONMENT", "production") + project = get_env("INFRA_PROJECT", "tasking-manager") + application = get_env("INFRA_APPLICATION", "tasking-manager") + short_name = get_env("INFRA_PROJECT_SHORT_NAME", "tm") + maintainer = get_env("INFRA_MAINTAINER", "sysadmin@hotosm.org") + url = get_env("INFRA_URL", "https://tasks.hotosm.org") + documentation = get_env("INFRA_DOCS_URL", "https://hotosm.github.io") + IaC_Management = get_env("INFRA_IAC_MANAGER", "Terraform/Terragrunt") + cost_center = get_env("INFRA_ENABLE_COST_CENTER", "False") + version = get_env("INFRA_APP_VERSION", "4.8.2") +} diff --git a/scripts/aws/infra/production/non-purgeable/alb/terragrunt.hcl b/scripts/aws/infra/production/non-purgeable/alb/terragrunt.hcl new file mode 100644 index 0000000000..b7da7c1821 --- /dev/null +++ b/scripts/aws/infra/production/non-purgeable/alb/terragrunt.hcl @@ -0,0 +1,26 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/alb.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.0" +} + +dependency "vpc" { + config_path = "../vpc" +} + +inputs = { + # General VPC Configuration + vpc_id = dependency.vpc.outputs.vpc_id + alb_subnets = dependency.vpc.outputs.public_subnets + + # TLS and Certificate Configuration + acm_tls_cert_backend_arn = get_env("ACM_TLS_CERT_BACKEND_ARN", "arn:aws:acm:us-east-1:670261699094:certificate/1d74321b-1e5b-4e31-b97a-580deb39c539") +} diff --git a/scripts/aws/infra/production/non-purgeable/cloudfront/terragrunt.hcl b/scripts/aws/infra/production/non-purgeable/cloudfront/terragrunt.hcl new file mode 100644 index 0000000000..d5b9aa6871 --- /dev/null +++ b/scripts/aws/infra/production/non-purgeable/cloudfront/terragrunt.hcl @@ -0,0 +1,73 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/cloudfront.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.1.0" +} + +dependency "s3" { + config_path = "../s3" +} + +inputs = { + # S3 bucket configuration - only need to provide the bucket name + s3_bucket_name = dependency.s3.outputs.bucket_name + create_s3_bucket_policy = true + + aliases = split(" ", get_env("CLOUDFRONT_DIST_ALIASES", "tasks.hotosm.org")) + + # CloudFront configuration + enabled = true + is_ipv6_enabled = true + comment = "Frontend Static distribution for ${include.envcommon.locals.distribution_name}" + default_root_object = "index.html" + + # Price class - choose one of: [ + # - PriceClass_100 (US, Canada, Europe) + # - PriceClass_200 (PriceClass_100 + South America, Australia, New Zealand, and parts of Asia) + # - PriceClass_All (All regions) + # ] + price_class = "PriceClass_All" + + # Cache behavior + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + forward_query_string = false + forward_cookies = "none" + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + compress = true + + # Geo restrictions + geo_restriction_type = "none" + geo_restriction_locations = [] + + custom_error_response_pages = [ + { + error_code = 404 + response_page_path = "/index.html" + response_code = 404 + error_caching_min_ttl = 60 + }, + { + error_code = 403 + response_page_path = "/index.html" + response_code = 403 + error_caching_min_ttl = 60 + } + ] + + # SSL/TLS configuration + use_default_certificate = false + # If use_default_certificate is false, provide these: + acm_certificate_arn = get_env("ACM_TLS_CERT_FRONTEND_ARN", "arn:aws:acm:us-east-1:670261699094:certificate/1d74321b-1e5b-4e31-b97a-580deb39c539") +} diff --git a/scripts/aws/infra/production/non-purgeable/ec2/README.md b/scripts/aws/infra/production/non-purgeable/ec2/README.md new file mode 100644 index 0000000000..6758e0f850 --- /dev/null +++ b/scripts/aws/infra/production/non-purgeable/ec2/README.md @@ -0,0 +1,9 @@ +## FAQ + +### Where to find SSH-Key ? + +```sh +$ find ./.terragrunt-cache -iname "*.pem" +``` + +This command shows path for the SSH key. diff --git a/scripts/aws/infra/production/non-purgeable/ec2/terragrunt.hcl b/scripts/aws/infra/production/non-purgeable/ec2/terragrunt.hcl new file mode 100644 index 0000000000..487b75a72e --- /dev/null +++ b/scripts/aws/infra/production/non-purgeable/ec2/terragrunt.hcl @@ -0,0 +1,63 @@ +include "root" { + path = find_in_parent_folders("root.hcl") +} + +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/ec2.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.1.0" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + dependency_writer = run_cmd("--terragrunt-quiet", "sh", "-c", < stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + #Enable Execute Command + enable_execute_command = false + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "python3 backend/cron_jobs.py" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 100 + max_pct = 200 + } + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 1 + container_max_count = 1 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) + + # Override image to use "main" image tag (default is environment variable eg. develop) + container_settings = { + app_port = 80 + cpu_architecture = "X86_64" + image_url = "ghcr.io/hotosm/tasking-manager/backend" + image_tag = "main" + service_name = format("%s-%s-%s-%s", local.environment_vars.locals.application, local.environment_vars.locals.team, local.environment_vars.locals.environment, "cron") + } +} diff --git a/scripts/aws/infra/production/purgeable/ecs/terragrunt.hcl b/scripts/aws/infra/production/purgeable/ecs/terragrunt.hcl new file mode 100644 index 0000000000..3614e1c4ca --- /dev/null +++ b/scripts/aws/infra/production/purgeable/ecs/terragrunt.hcl @@ -0,0 +1,168 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# This is the configuration for Terragrunt, a thin wrapper for Terraform and OpenTofu that helps keep your code DRY and +# maintainable: https://github.com/gruntwork-io/terragrunt +# --------------------------------------------------------------------------------------------------------------------- + +# Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all +# components and environments, such as how to configure remote state. +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include the envcommon configuration for the component. The envcommon configuration contains settings that are common +# for the component across all environments. +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/ecs.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +# Configure the version of the module to use in this environment. This allows you to promote new versions one +# environment at a time (e.g., qa -> stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + load_balancer_settings = { + enabled = true + target_group_arn = dependency.alb.outputs.target_group_arn + target_group_arn_suffix = dependency.alb.outputs.target_group_arn_suffix + arn_suffix = dependency.alb.outputs.load_balancer_arn_suffix + scaling_request_count = 200 + } + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + #Enable Execute Command + enable_execute_command = false + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "alembic -c migrations/alembic.ini upgrade head && uvicorn backend.main:api --host 0.0.0.0 --port 5000 --workers 8 --log-level critical --no-access-log" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 25 + max_pct = 200 + } + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 2 + container_max_count = 9 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) + + # Override image to use "main" image tag (default is environment variable eg. develop) + container_settings = { + app_port = 80 + cpu_architecture = "X86_64" + image_url = "ghcr.io/hotosm/tasking-manager/backend" + image_tag = "main" + service_name = format("%s-%s-%s-%s", local.environment_vars.locals.application, local.environment_vars.locals.team, local.environment_vars.locals.environment, "fastapi") + } + + # terraform.tfvars + + cpu_scaling_config = { + enabled = true + threshold = 60 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = 20, adjustment = 2 }, + { lower_bound = 20, upper_bound = null, adjustment = 3 } + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = -10, adjustment = -2 }, + { lower_bound = -10, upper_bound = 0, adjustment = -1 } + ] + } + + memory_scaling_config = { + enabled = true + threshold = 70 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = 20, adjustment = 2 }, + { lower_bound = 20, upper_bound = null, adjustment = 3 } + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = -10, adjustment = -2 }, + { lower_bound = -10, upper_bound = 0, adjustment = -1 } + ] + } + + request_scaling_config = { + enabled = true + threshold = 100 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 300, adjustment = 1 }, + { lower_bound = 300, upper_bound = 900, adjustment = 2 }, + { lower_bound = 900, upper_bound = null, adjustment = 3 } + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = -200, adjustment = -2 }, + { lower_bound = -200, upper_bound = -100, adjustment = -1 } + ] + } +} diff --git a/scripts/aws/infra/root.hcl b/scripts/aws/infra/root.hcl new file mode 100644 index 0000000000..5d8a272598 --- /dev/null +++ b/scripts/aws/infra/root.hcl @@ -0,0 +1,99 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# Terragrunt is a thin wrapper for Terraform that provides extra tools for working with multiple Terraform modules, +# remote state, and locking: https://github.com/gruntwork-io/terragrunt +# --------------------------------------------------------------------------------------------------------------------- + +locals { + # Automatically load deployment environment-level variables + deployment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + + # Extract variables for easy access + account_name = local.deployment_vars.locals.account_name + aws_profile = local.deployment_vars.locals.aws_profile + aws_region = local.deployment_vars.locals.aws_region + environment = local.deployment_vars.locals.environment + application = local.deployment_vars.locals.application + team = local.deployment_vars.locals.team + owner = local.deployment_vars.locals.owner + url = local.deployment_vars.locals.url + short_name = local.deployment_vars.locals.short_name + + # Default tags + default_tags = { + environment = local.environment + project = local.deployment_vars.locals.project + maintainer = local.deployment_vars.locals.maintainer + documentation = local.deployment_vars.locals.documentation + costcenter = local.deployment_vars.locals.cost_center != null ? local.deployment_vars.locals.cost_center : "" + iac_management = local.deployment_vars.locals.IaC_Management + team = local.team + creator = local.account_name # Assuming account_name is used as Creator + owner = local.owner + url = local.url + } +} + +# Generate AWS provider block. +generate "provider" { + path = "provider.tf" + if_exists = "overwrite_terragrunt" + contents = < stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + #Enable Execute Command + enable_execute_command = false + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "python3 backend/cron_jobs.py" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 100 + max_pct = 200 + } + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 1 + container_max_count = 1 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) +} diff --git a/scripts/aws/infra/staging/purgeable/ecs/terragrunt.hcl b/scripts/aws/infra/staging/purgeable/ecs/terragrunt.hcl new file mode 100644 index 0000000000..1baf12e848 --- /dev/null +++ b/scripts/aws/infra/staging/purgeable/ecs/terragrunt.hcl @@ -0,0 +1,153 @@ +# --------------------------------------------------------------------------------------------------------------------- +# TERRAGRUNT CONFIGURATION +# This is the configuration for Terragrunt, a thin wrapper for Terraform and OpenTofu that helps keep your code DRY and +# maintainable: https://github.com/gruntwork-io/terragrunt +# --------------------------------------------------------------------------------------------------------------------- + +# Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all +# components and environments, such as how to configure remote state. +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include the envcommon configuration for the component. The envcommon configuration contains settings that are common +# for the component across all environments. +include "envcommon" { + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/ecs.hcl" + # We want to reference the variables from the included config in this configuration, so we expose it. + expose = true +} + +# Configure the version of the module to use in this environment. This allows you to promote new versions one +# environment at a time (e.g., qa -> stage -> prod). +terraform { + source = "${include.envcommon.locals.base_source_url}?ref=v1.2.1" +} + +locals { + # Automatically load environment-level variables + environment_vars = read_terragrunt_config(find_in_parent_folders("deployment_env.hcl")) + common_ecs_envs = read_terragrunt_config(find_in_parent_folders("common-ecs-env.hcl")) +} + +# --------------------------------------------------------------------------------------------------------------------- +# We don't need to override any of the common parameters for this environment, so we don't specify any other parameters. +# --------------------------------------------------------------------------------------------------------------------- + +dependency "vpc" { + config_path = "../../non-purgeable/vpc" +} + +dependency "alb" { + config_path = "../../non-purgeable/alb" +} + +dependency "rds" { + config_path = "../../non-purgeable/rds" +} + +dependency "extras" { + config_path = "../../non-purgeable/extras" +} + +## Add in any new inputs that you want to overide. +inputs = { + # Inputs from dependencies (Rarely changed) + service_subnets = dependency.vpc.outputs.private_subnets + aws_vpc_id = dependency.vpc.outputs.vpc_id + service_security_groups = [dependency.alb.outputs.load_balancer_app_security_group] + deployment_environment = local.environment_vars.locals.environment + load_balancer_settings = { + enabled = true + target_group_arn = dependency.alb.outputs.target_group_arn + target_group_arn_suffix = dependency.alb.outputs.target_group_arn_suffix + arn_suffix = dependency.alb.outputs.load_balancer_arn_suffix + scaling_request_count = 200 + } + task_role_arn = dependency.extras.outputs.ecs_task_role_arn + service_security_groups = [ + dependency.alb.outputs.load_balancer_app_security_group + ] + + #Enable Execute Command + enable_execute_command = false + + # Merge secrets with: key:ValueFrom together + container_secrets = concat(dependency.extras.outputs.container_secrets, + dependency.rds.outputs.database_config_as_ecs_secrets_inputs) + + container_commands = [ + "sh", + "-c", + "alembic -c migrations/alembic.ini upgrade head && uvicorn backend.main:api --host 0.0.0.0 --port 5000 --workers 8 --log-level critical --no-access-log" + ] + + ## Task count for ECS services. + tasks_count = { + desired_count = 1 + min_healthy_pct = 25 + max_pct = 200 + } + + ## Scaling Policy Target Values + scaling_target_values = { + container_min_count = 1 + container_max_count = 3 + } + + # Merge non-sensetive together + container_envvars = merge( + dependency.rds.outputs.database_config_as_ecs_inputs, + local.common_ecs_envs.locals.envs + ) + + # terraform.tfvars + + cpu_scaling_config = { + enabled = true + threshold = 60 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = null, adjustment = 2 } + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } + + memory_scaling_config = { + enabled = true + threshold = 70 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 10, adjustment = 1 }, + { lower_bound = 10, upper_bound = null, adjustment = 2 }, + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } + + request_scaling_config = { + enabled = true + threshold = 100 + evaluation_periods = 1 + period = 30 + scale_up_cooldown = 60 + scale_down_cooldown = 180 + scale_up_steps = [ + { lower_bound = 0, upper_bound = 100, adjustment = 1 }, + { lower_bound = 100, upper_bound = null, adjustment = 2 }, + ] + scale_down_steps = [ + { lower_bound = null, upper_bound = 0, adjustment = -1 } + ] + } +} diff --git a/scripts/aws/infra/terraform-aws-extras/outputs.tf b/scripts/aws/infra/terraform-aws-extras/outputs.tf new file mode 100644 index 0000000000..8978813a18 --- /dev/null +++ b/scripts/aws/infra/terraform-aws-extras/outputs.tf @@ -0,0 +1,14 @@ +# Outputs +output "container_secrets" { + value = [ + for secret in var.container_secrets : { + name = secret.name + valueFrom = aws_secretsmanager_secret.tm_secrets[secret.name].arn # Get the ARN of the secret + } + ] +} + +output "ecs_task_role_arn" { + value = aws_iam_role.task_role.arn + description = "ARN of the task role for ECS" +} diff --git a/scripts/aws/infra/terraform-aws-extras/secrets.tf b/scripts/aws/infra/terraform-aws-extras/secrets.tf new file mode 100644 index 0000000000..58917a8741 --- /dev/null +++ b/scripts/aws/infra/terraform-aws-extras/secrets.tf @@ -0,0 +1,26 @@ +# Resource for AWS Secrets Manager +resource "aws_secretsmanager_secret" "tm_secrets" { + for_each = { + for secret in var.container_secrets : secret.name => secret + } + + name = join("/", [ + lookup(var.org_meta, "url"), + lookup(var.project_meta, "short_name"), + var.deployment_environment, + "backend", + each.key + ]) + + description = "Container secret for the ${lookup(var.project_meta, "name")} ${var.deployment_environment} instance" +} + +# Define secret versions +resource "aws_secretsmanager_secret_version" "tm_secret_version" { + for_each = { + for secret in var.container_secrets : secret.name => secret + } + + secret_id = aws_secretsmanager_secret.tm_secrets[each.key].id + secret_string = each.value.valueFrom +} diff --git a/scripts/aws/infra/terraform-aws-extras/task-role.tf b/scripts/aws/infra/terraform-aws-extras/task-role.tf new file mode 100644 index 0000000000..ffb3f07fd2 --- /dev/null +++ b/scripts/aws/infra/terraform-aws-extras/task-role.tf @@ -0,0 +1,130 @@ +# ECS Role and Policy + +resource "aws_iam_role" "task_role" { + name = format("%s-%s-%s",lookup(var.project_meta, "name"),"ecs-role" ,"${var.deployment_environment}" ) + assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json + tags = { + Name = format("%s-%s-%s",lookup(var.project_meta, "name"),"ecs-role" ,"${var.deployment_environment}" ) + } +} + +resource "aws_iam_policy" "ecs_policy" { + name = format("%s-%s-%s",lookup(var.project_meta, "name"),"ecs-policy" ,"${var.deployment_environment}" ) + description = format("%s-%s-%s-%s","ECS Policy For", lookup(var.project_meta, "name"),"ecs-role" ,"${var.deployment_environment}" ) + policy = data.aws_iam_policy_document.ecs_policy.json + tags = { + Name = format("%s-%s-%s",lookup(var.project_meta, "name"),"ecs-policy" ,"${var.deployment_environment}" ) + } +} + +resource "aws_iam_role_policy_attachment" "ecs_attachment_1" { + role = aws_iam_role.task_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy_attachment" "ecs_attachment_2" { + role = aws_iam_role.task_role.name + policy_arn = aws_iam_policy.ecs_policy.arn +} + +# ===================== DATA POLICY ============================ + +data "aws_iam_policy_document" "ecs_assume_role" { + statement { + sid = "GenericAssumeRoleEC2" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ecs_policy" { + statement { + sid = "AllowECRActions" + effect = "Allow" + + actions = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability" + ] + + resources = [ + "*" + ] + } + + statement { + sid = "AllowECRECRGetAuthorizationToken" + effect = "Allow" + + actions = [ + "ecr:GetAuthorizationToken" + ] + + resources = ["*"] + } + + statement { + sid = "ECSs3Bucket" + effect = "Allow" + + resources = [ + "arn:aws:s3:::${var.s3_bucket_name}/*", + "arn:aws:s3:::${var.s3_bucket_name}", + ] + + actions = [ + "s3:ListBucket", + "s3:PutObject", + "s3:GetEncryptionConfiguration", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + ] + } + + statement { + sid = "ECSssmMessages" + effect = "Allow" + resources = ["*"] + + actions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ] + } + + statement { + sid = "ECSlogs" + effect = "Allow" + resources = ["arn:aws:logs:*:*:*"] + + actions = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + ] + } + + statement { + sid = "ECScloudwatch" + effect = "Allow" + resources = ["*"] + + actions = [ + "cloudwatch:PutMetricData", + "cloudwatch:GetMetricData", + "cloudwatch:ListMetrics", + ] + } + +} diff --git a/scripts/aws/infra/terraform-aws-extras/variables.tf b/scripts/aws/infra/terraform-aws-extras/variables.tf new file mode 100644 index 0000000000..cdb12c578d --- /dev/null +++ b/scripts/aws/infra/terraform-aws-extras/variables.tf @@ -0,0 +1,45 @@ +variable "org_meta" { + description = "Org info for secrets manager prefix" + type = map(string) + default = { + name = "hotosm.org" + short_name = "hot" + url = "hotosm.org" + } +} + +variable "project_meta" { + description = "Metadata relating to the project for which the VPC is being created" + type = map(string) + + default = { + name = "tasking-manager" + short_name = "tm" + version = "1.1.2" + image_tag = "develop" + url = "https://tasks.hotosm.org" + } +} + +variable "s3_bucket_name" { + type = string + description = "S3 Bucket to store state files for terraform" + default = "tasking-manager-terraform" +} + +variable "deployment_environment" { + description = "Deployment flavour or variant identified by this name" + type = string + + default = "dev" +} + +variable "container_secrets" { + description = "Secrets from Secrets Manager to pass to containers" + type = list(object({ + name = string + valueFrom = string + })) + nullable = true + default = [] +} diff --git a/scripts/aws/lambda/TM-Extractor/dev/tm-extractor/terragrunt.hcl b/scripts/aws/lambda/TM-Extractor/dev/tm-extractor/terragrunt.hcl index ddb6710a01..0eacf2ba6b 100644 --- a/scripts/aws/lambda/TM-Extractor/dev/tm-extractor/terragrunt.hcl +++ b/scripts/aws/lambda/TM-Extractor/dev/tm-extractor/terragrunt.hcl @@ -11,13 +11,13 @@ # Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all # components and environments, such as how to configure remote state. include "root" { - path = find_in_parent_folders() + path = find_in_parent_folders("root.hcl") } # Include the envcommon configuration for the component. The envcommon configuration contains settings that are common # for the component across all environments. include "envcommon" { - path = "${dirname(find_in_parent_folders())}/_envcommon/lambda.hcl" + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/lambda.hcl" } # --------------------------------------------------------------------------------------------------------------------- diff --git a/scripts/aws/lambda/TM-Extractor/prod/tm-extractor/terragrunt.hcl b/scripts/aws/lambda/TM-Extractor/prod/tm-extractor/terragrunt.hcl index a189413910..4c370745ae 100644 --- a/scripts/aws/lambda/TM-Extractor/prod/tm-extractor/terragrunt.hcl +++ b/scripts/aws/lambda/TM-Extractor/prod/tm-extractor/terragrunt.hcl @@ -11,13 +11,13 @@ # Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all # components and environments, such as how to configure remote state. include "root" { - path = find_in_parent_folders() + path = find_in_parent_folders("root.hcl") } # Include the envcommon configuration for the component. The envcommon configuration contains settings that are common # for the component across all environments. include "envcommon" { - path = "${dirname(find_in_parent_folders())}/_envcommon/lambda.hcl" + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/lambda.hcl" } # --------------------------------------------------------------------------------------------------------------------- diff --git a/scripts/aws/lambda/TM-Extractor/stag/tm-extractor/terragrunt.hcl b/scripts/aws/lambda/TM-Extractor/stag/tm-extractor/terragrunt.hcl index 567fc8049b..d558988c42 100644 --- a/scripts/aws/lambda/TM-Extractor/stag/tm-extractor/terragrunt.hcl +++ b/scripts/aws/lambda/TM-Extractor/stag/tm-extractor/terragrunt.hcl @@ -11,13 +11,13 @@ # Include the root `terragrunt.hcl` configuration. The root configuration contains settings that are common across all # components and environments, such as how to configure remote state. include "root" { - path = find_in_parent_folders() + path = find_in_parent_folders("root.hcl") } # Include the envcommon configuration for the component. The envcommon configuration contains settings that are common # for the component across all environments. include "envcommon" { - path = "${dirname(find_in_parent_folders())}/_envcommon/lambda.hcl" + path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/lambda.hcl" } # --------------------------------------------------------------------------------------------------------------------- diff --git a/scripts/commands/update_user_level.py b/scripts/commands/update_user_level.py new file mode 100644 index 0000000000..a40046fe30 --- /dev/null +++ b/scripts/commands/update_user_level.py @@ -0,0 +1,95 @@ +import asyncio +import sys +import os +import argparse +from backend.db import db_connection +from backend.services.users.user_service import UserService +from backend.services.users.osm_service import OSMService, OSMServiceError +from backend.models.postgis.user import MappingLevel +from backend.config import settings +import logging + +# Add backend to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# The script verifies if the user is advanced or not and demotes to the respective level if not. +async def fix_advanced_users(exclude_ids=None): + await db_connection.connect() + db = db_connection.database + + advanced_users = await db.fetch_all( + "SELECT id, username FROM users WHERE mapping_level = :level", + {"level": MappingLevel.ADVANCED.value}, + ) + logger.info(f"Found {len(advanced_users)} advanced users") + + downgraded_users = [] + + for user in advanced_users: + logger.info(f"----- Processing for user: {user.username}-{user.id} -----") + user_id = user.id + + if exclude_ids and user_id in exclude_ids: + logger.info(f"Skipping user {user.username} (ID: {user_id}) [Excluded]") + continue + + try: + osm_details = OSMService.get_osm_details_for_user(user_id) + changeset_count = osm_details.changeset_count + + if changeset_count >= settings.MAPPER_LEVEL_ADVANCED: + logger.info( + f"[{user.username}-{user_id}] OK as ADVANCED ({changeset_count} changesets)" + ) + continue # Still qualifies as ADVANCED + + # Determine correct level + if changeset_count >= settings.MAPPER_LEVEL_INTERMEDIATE: + new_level = MappingLevel.INTERMEDIATE + else: + new_level = MappingLevel.BEGINNER + + await db.execute( + """ + UPDATE users + SET mapping_level = :new_level + WHERE id = :user_id + """, + {"new_level": new_level.value, "user_id": user_id}, + ) + logger.warning( + f"[{user.username}-{user_id}] Downgraded to {new_level.name} ({changeset_count} changesets)" + ) + downgraded_users.append((user.username, user_id, new_level.name)) + + except OSMServiceError: + logger.error(f"[{user_id}] Failed to fetch OSM details. Skipping.") + except Exception as e: + logger.exception(f"[{user_id}] Unexpected error: {e}") + + await db_connection.disconnect() + + if downgraded_users: + logger.info("\n--- Downgraded Users ---") + for username, user_id, new_level in downgraded_users: + print(f"{username} (ID: {user_id}) → {new_level}") + else: + logger.info("\nNo users were downgraded.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Fix incorrectly advanced mappers.") + parser.add_argument( + "--exclude", + help="Comma-separated list of user IDs to exclude", + default="", + ) + args = parser.parse_args() + exclude_ids = ( + list(map(int, args.exclude.split(","))) if args.exclude.strip() else [] + ) + asyncio.run(fix_advanced_users(exclude_ids=exclude_ids)) diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile new file mode 100644 index 0000000000..633379b129 --- /dev/null +++ b/scripts/docker/Dockerfile @@ -0,0 +1,158 @@ +ARG DEBIAN_IMG_TAG=slim-bookworm +ARG PYTHON_IMG_TAG=3.10 +ARG UV_IMG_TAG=0.5.2 +FROM ghcr.io/astral-sh/uv:${UV_IMG_TAG} AS uv + +FROM docker.io/python:${PYTHON_IMG_TAG}-${DEBIAN_IMG_TAG} AS base + +ARG APP_VERSION=5.1.0 +ARG DOCKERFILE_VERSION=1.0.0 +ARG DEBIAN_IMG_TAG +ARG PYTHON_IMG_TAG +ARG MAINTAINER=sysadmin@hotosm.org + +LABEL org.hotosm.tasks.app-version="${APP_VERSION}" \ + org.hotosm.tasks.debian-img-tag="${DEBIAN_IMG_TAG}" \ + org.hotosm.tasks.python-img-tag="${PYTHON_IMG_TAG}" \ + org.hotosm.tasks.dockerfile-version="${DOCKERFILE_VERSION}" \ + org.hotosm.tasks.maintainer="${MAINTAINER}" \ + org.hotosm.tasks.api-port="5000" + +# Fix timezone (do not change - see issue #3638) +ENV TZ=UTC + +# Add non-root user +RUN useradd --uid 9000 --create-home --home /home/appuser --shell /bin/false appuser + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive \ + apt-get -q install --no-install-recommends -y \ + build-essential \ + postgresql-server-dev-15 \ + python3-dev \ + libffi-dev \ + libgeos-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# - Silence uv complaining about not being able to use hard links, +# - tell uv to byte-compile packages for faster application startups, +# - prevent uv from accidentally downloading isolated Python builds, +# - use a temp dir instead of cache during install, +# - select system python version, +# - declare `/opt/python` as the target for `uv sync` (i.e. instead of .venv). +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=never \ + UV_NO_CACHE=1 \ + UV_PYTHON="python$PYTHON_IMG_TAG" \ + UV_PROJECT_ENVIRONMENT=/opt/python +STOPSIGNAL SIGINT + +FROM base AS build + +# Copy UV binary from installer stage +COPY --from=uv /uv /usr/local/bin/uv + + +# Copy dependency specs +COPY --chown=appuser:appuser --chmod=755 pyproject.toml uv.lock /_lock/ + +# Ensure caching & install with or without monitoring, depending on flag +RUN --mount=type=cache,target=/root/.cache < ../tasking-manager.env +RUN yarn build -FROM nginx:stable-alpine -COPY --from=build /usr/src/app/frontend/build /usr/share/nginx/html -COPY scripts/docker/nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +FROM nginx:alpine AS prod +COPY --from=build /usr/src/app/build /usr/share/nginx/html +COPY scripts/docker/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3000 CMD ["nginx", "-g", "daemon off;"] diff --git a/scripts/docker/Dockerfile.frontend_development b/scripts/docker/Dockerfile.frontend_development deleted file mode 100644 index 2517976273..0000000000 --- a/scripts/docker/Dockerfile.frontend_development +++ /dev/null @@ -1,15 +0,0 @@ -FROM node:22.13.0 - -WORKDIR /usr/src/app - -## SETUP - -COPY ./frontend/package.json ./ -COPY ./frontend/yarn.lock ./ - -RUN yarn install --ignore-scripts - -COPY ./frontend . - -# SERVE -CMD ["yarn", "start"] diff --git a/scripts/docker/nginx.conf b/scripts/docker/nginx.conf index fd4472f4b5..e94f1d708e 100644 --- a/scripts/docker/nginx.conf +++ b/scripts/docker/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 3000; location / { root /usr/share/nginx/html; diff --git a/scripts/install/install_on_debian_10_buster.sh b/scripts/install/install_on_debian_10_buster.sh index a447c2bf42..3a16f3e3d6 100755 --- a/scripts/install/install_on_debian_10_buster.sh +++ b/scripts/install/install_on_debian_10_buster.sh @@ -31,8 +31,8 @@ git clone https://github.com/hotosm/tasking-manager.git && ## Prepare the tasking manager cd tasking-manager/ && pip install --upgrade pip && -pip install --upgrade pdm && -pdm install && +pip install --upgrade uv && +uv sync && # Set up configuration # Sets db endpoint to localhost. @@ -65,7 +65,7 @@ do sudo -u postgres psql -c "alter table \"$tbl\" owner to $POSTGRES_USER" $POS cd ../../ && # Upgrade database -pdm run flask db upgrade && +uv run flask db upgrade && # Assamble the tasking manager interface cd frontend/ && @@ -76,4 +76,4 @@ cd ../ && ## Please edit the tasking-manager.env as indicated in the README.md ## # Start the tasking manager -pdm run flask run -d +uv run flask run -d diff --git a/scripts/install/install_on_ubuntu_18_04.sh b/scripts/install/install_on_ubuntu_18_04.sh index c7fa3a0540..c59a6d3f99 100755 --- a/scripts/install/install_on_ubuntu_18_04.sh +++ b/scripts/install/install_on_ubuntu_18_04.sh @@ -31,8 +31,8 @@ git clone https://github.com/hotosm/tasking-manager.git && ## Prepare the tasking manager cd tasking-manager/ && pip install --upgrade pip && -pip install --upgrade pdm && -pdm install && +pip install --upgrade uv && +uv sync && echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p && # Set up configuration @@ -66,7 +66,7 @@ do sudo -u postgres psql -c "alter table \"$tbl\" owner to $POSTGRES_USER" $POS cd ../../ && # Upgrade database -pdm run flask db upgrade && +uv run flask db upgrade && # Assemble the tasking manager interface cd frontend/ && @@ -77,4 +77,4 @@ cd ../ && ## Please edit the tasking-manager.env as indicated in the README.md ## # Start the tasking manager -pdm run flask run -d +uv run flask run -d diff --git a/scripts/ohm/preserve_upstream_docs.sh b/scripts/ohm/preserve_upstream_docs.sh old mode 100755 new mode 100644 diff --git a/tests/api/base.py b/tests/api/base.py new file mode 100644 index 0000000000..9b7262a4be --- /dev/null +++ b/tests/api/base.py @@ -0,0 +1,43 @@ +from shapely.geometry import shape + + +# Code modified from https://github.com/larsbutler/oq-engine/blob/master/tests/utils/helpers.py +# Note: Was originally in test_resources.py (author Aadesh-Baral) +def assertDeepAlmostEqual(self, expected, actual, *args, **kwargs): + """ + Assert that two complex structures have almost equal contents. + + Compares lists, dicts and tuples recursively. Checks numeric values + using test_case's :py:meth:`unittest.TestCase.assertAlmostEqual` and + checks all other values with :py:meth:`unittest.TestCase.assertEqual`. + Accepts additional positional and keyword arguments and pass those + intact to assertAlmostEqual() (that's how you specify comparison + precision). + + :type test_case: :py:class:`unittest.TestCase` object + """ + kwargs.pop("__trace", "ROOT") + if ( + hasattr(expected, "__geo_interface__") + and hasattr(actual, "__geo_interface__") + and expected.__geo_interface__["type"] == actual.__geo_interface__["type"] + and expected.__geo_interface__["type"] not in ["Feature", "FeatureCollection"] + ): + shape_expected = shape(expected) + shape_actual = shape(actual) + assert shape_expected.equals(shape_actual) + elif isinstance(expected, (int, float, complex)): + self.assertAlmostEqual(expected, actual, *args, **kwargs) + elif isinstance(expected, (list, tuple)): + self.assertEqual(len(expected), len(actual)) + for index in range(len(expected)): + v1, v2 = expected[index], actual[index] + self.assertDeepAlmostEqual(v1, v2, __trace=repr(index), *args, **kwargs) + elif isinstance(expected, dict): + self.assertEqual(set(expected), set(actual)) + for key in expected: + self.assertDeepAlmostEqual( + expected[key], actual[key], __trace=repr(key), *args, **kwargs + ) + else: + self.assertEqual(expected, actual) diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000000..68e8151632 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,160 @@ +# conftest.py +import logging +import pytest +import asyncpg +from databases import Database +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.sql import text +from httpx import ASGITransport, AsyncClient + +from backend.config import test_settings as settings +from backend.db import Base, db_connection +from backend.routes import add_api_end_points + + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.middleware.authentication import AuthenticationMiddleware +from backend.services.users.authentication_service import TokenAuthBackend + +# Logging setup +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# DB URLs +db_url = settings.SQLALCHEMY_DATABASE_URI.unicode_string() +pfx, db_name = db_url.rsplit("/", 1) +ASYNC_TEST_DB_URL = f"{pfx}/{db_name}_test" +SYNCPG_DB_URL = str(settings.SQLALCHEMY_DATABASE_URI).replace( + "postgresql+asyncpg://", "postgresql://" +) +TEST_DB_NAME = f"{db_name}_test" # Use the same name consistently + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="session") +async def test_database(): + """Fixture to create and drop test database""" + # Connect to default postgres database to create test db + conn = await asyncpg.connect(dsn=SYNCPG_DB_URL.rsplit("/", 1)[0] + "/postgres") + try: + await conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") + await conn.execute(f"CREATE DATABASE {TEST_DB_NAME}") + finally: + await conn.close() + + # Create tables and extensions + engine = create_async_engine(ASYNC_TEST_DB_URL) + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis;")) + await conn.run_sync(Base.metadata.create_all) + await engine.dispose() + + yield # Test run happens here + + # Cleanup + conn = await asyncpg.connect(dsn=SYNCPG_DB_URL.rsplit("/", 1)[0] + "/postgres") + try: + await conn.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") + finally: + await conn.close() + + +@pytest.fixture +async def db_connection_fixture(test_database): + """Database connection fixture with automatic rollback""" + test_db = Database(ASYNC_TEST_DB_URL, min_size=4, max_size=8, force_rollback=True) + await test_db.connect() + try: + yield test_db + finally: + await test_db.disconnect() + + +def create_test_app(): + """Create a FastAPI app specifically for testing without lifespan events.""" + + _app = FastAPI( + title=settings.APP_NAME, + description="HOTOSM Tasking Manager - Test Mode", + version="0.1.0-test", + debug=True, + openapi_url="/api/openapi.json", + docs_url="/api/docs", + ) + + @_app.exception_handler(HTTPException) + async def custom_http_exception_handler(request: Request, exc: HTTPException): + if exc.status_code == 401 and "InvalidToken" in exc.detail.get("SubCode", ""): + return JSONResponse( + content={ + "Error": exc.detail["Error"], + "SubCode": exc.detail["SubCode"], + }, + status_code=exc.status_code, + headers={"WWW-Authenticate": "Bearer"}, + ) + + if isinstance(exc.detail, dict) and "error" in exc.detail: + error_response = exc.detail + else: + error_response = { + "error": { + "code": exc.status_code, + "sub_code": "UNKNOWN_ERROR", + "message": str(exc.detail), + "details": {}, + } + } + + return JSONResponse( + status_code=exc.status_code, + content=error_response, + ) + + # Add middleware + _app.add_middleware( + CORSMiddleware, + allow_origins=settings.EXTRA_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Disposition"], + ) + + _app.add_middleware( + AuthenticationMiddleware, backend=TokenAuthBackend(), on_error=None + ) + + # Add API endpoints + add_api_end_points(_app) + return _app + + +@pytest.fixture(scope="session") +def test_app(): + """Session-scoped test app""" + return create_test_app() + + +@pytest.fixture +async def app(test_app, db_connection_fixture): + """Reuse session app with test-specific connection""" + + # Inject test connection + db_connection.database = db_connection_fixture + yield test_app + + +@pytest.fixture +async def client(app): + """Test client fixture""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://test" + ) as ac: + yield ac diff --git a/tests/api/helpers/test_files/2d_multi_polygon.json b/tests/api/helpers/test_files/2d_multi_polygon.json new file mode 100644 index 0000000000..164125cc5f --- /dev/null +++ b/tests/api/helpers/test_files/2d_multi_polygon.json @@ -0,0 +1,37 @@ +{ + "coordinates": [ + [ + [ + [ + -4.65996216497632, + 55.95041925350509 + ], + [ + -4.95173413390339, + 56.2627945145455 + ], + [ + -4.67367146099783, + 56.2434516969104 + ], + [ + -4.66709656284914, + 56.2435936767028 + ], + [ + -4.0046243330544, + 56.03981437664169 + ], + [ + -4.35742797997559, + 55.48727395069889 + ], + [ + -4.65996216497632, + 55.95041925350509 + ] + ] + ] + ], + "type": "MultiPolygon" +} diff --git a/tests/api/helpers/test_files/CHAI-Escuintla-West2.json b/tests/api/helpers/test_files/CHAI-Escuintla-West2.json new file mode 100644 index 0000000000..eec661d63c --- /dev/null +++ b/tests/api/helpers/test_files/CHAI-Escuintla-West2.json @@ -0,0 +1 @@ +{"areaOfInterest":{"type":"FeatureCollection","features":[{"type":"Feature","id":"way/-203564","geometry":{"type":"LineString","coordinates":[[-10127048.178416166,1617084.2643527186],[-10127124.615444696,1564294.9416937805],[-10134672.771987133,1564333.1602070213],[-10139899.153796503,1564562.4712926599],[-10145603.267032227,1565135.7490041214],[-10150953.859012537,1565699.4720884543],[-10154116.441057198,1566205.8674002888],[-10162018.118856166,1567973.4736797952],[-10164273.011190526,1568575.4152773896],[-10171639.629792018,1571585.1232656306],[-10178710.054908102,1574566.1673690565],[-10183783.562660413,1576926.1606175983],[-10189716.986981722,1579343.4816372513],[-10189134.154640723,1580021.8602626226],[-10189640.549953194,1581149.3064306113],[-10189248.810182959,1582228.9794543462],[-10189363.465725195,1583595.29133491],[-10187490.758532364,1585200.4689291308],[-10186497.07716483,1586604.99932385],[-10186076.673509594,1588028.6389762075],[-10184366.395001413,1589519.1610274045],[-10183888.663575057,1592051.13758973],[-10181691.099011812,1594200.9290093365],[-10180936.28335746,1595271.047406339],[-10180668.753758166,1596197.8463731967],[-10179980.820503633,1596885.7796285297],[-10179933.047361221,1597545.0489974308],[-10180525.434329813,1598080.108195309],[-10179684.627019336,1598538.73036506],[-10179598.635362104,1599006.907163535],[-10180143.249188283,1600086.580187673],[-10180019.03901734,1602064.3882947403],[-10180334.341759047,1602838.3132062345],[-10180171.9130744,1603354.263147198],[-10179942.601989927,1603545.3557177363],[-10180620.980615752,1604997.6592547125],[-10181442.678669931,1605045.4323972866],[-10181328.023126582,1605666.4832528231],[-10180592.316729637,1606402.1896499498],[-10180754.745415399,1607797.1654164798],[-10181165.594441932,1608523.3171851905],[-10180955.392613756,1609536.1078099879],[-10179980.820503633,1610204.9318074128],[-10179512.64370487,1610997.9659759416],[-10178442.525308808,1611303.7140886895],[-10174018.73229732,1612870.6731685624],[-10173273.47127167,1612813.3453974952],[-10173111.042587021,1613176.4212819359],[-10172919.950016256,1614141.4387640653],[-10172776.630587902,1615240.2210453018],[-10171916.714020018,1615536.414529353],[-10171649.184420723,1617246.6930375884],[-10170598.175282072,1617514.222636646],[-10170177.77162572,1617944.1809211902],[-10168821.014374068,1617982.3994346226],[-10166518.348897293,1619950.6529135099],[-10166059.726727236,1619998.4260565552],[-10166069.28135594,1619750.0057145548],[-10167330.492322769,1617686.2059509377],[-10166986.525694946,1617437.785608549],[-10167273.16455165,1616960.0541812328],[-10167340.046951473,1616224.3477844433],[-10167999.316319888,1614647.8340763669],[-10167550.248778535,1613243.3036815731],[-10168534.37551736,1611676.3446018011],[-10168123.526490832,1610625.3354631204],[-10167043.853466062,1611246.386317547],[-10161425.731887572,1609660.3179806576],[-10161263.30320181,1611208.1678030351],[-10160776.017146748,1611896.1010576806],[-10159858.772807743,1612134.9667713104],[-10159858.772807743,1612861.118539615],[-10158635.780354623,1612746.4629975522],[-10158588.00721221,1612498.0426552794],[-10159381.041380275,1610816.428033054],[-10159228.16732433,1610071.1670079106],[-10159533.915437331,1609106.1495251525],[-10147571.520510998,1606937.248847834],[-10147151.116854647,1607233.4423329392],[-10146587.393771054,1606660.1646199096],[-10146358.082686583,1607099.6775324112],[-10146252.98177305,1608074.2496439232],[-10146482.292857524,1608446.8801567464],[-10146243.427144347,1609306.7967242857],[-10145889.905888932,1610281.368835586],[-10145373.955947755,1611074.4030037387],[-10144619.1402934,1611064.848375511],[-10144399.383836519,1612058.529743294],[-10144284.728294283,1612268.731571024],[-10144466.266236342,1613558.6064233307],[-10144236.95515187,1615230.6664169363],[-10144733.795835637,1615756.1709864808],[-10144819.78749287,1616673.4153254433],[-10145373.955947755,1617198.9198945775],[-10143549.021897338,1618995.1900599198],[-10143185.946013218,1619979.3167994628],[-10141943.844302688,1621307.4101653951],[-10141246.356420564,1621603.6036501292],[-10140749.515735684,1621412.5110790848],[-10140567.977793625,1620896.5611379454],[-10140099.800995972,1620781.9055963007],[-10139536.077912383,1620285.0649124421],[-10138943.690942677,1620829.678738686],[-10137902.236432731,1622119.553590554],[-10136669.689352019,1622367.9739328555],[-10135895.764440253,1623428.5377007416],[-10133717.309134422,1623132.3442154143],[-10133354.23324919,1621985.7887918265],[-10133698.199877013,1620848.7879962025],[-10132035.694511242,1621536.7212505806],[-10131768.164913062,1622625.948903151],[-10131156.668685947,1622836.1507315093],[-10131022.903887413,1623973.1515267442],[-10129933.67623394,1624670.6394095276],[-10129675.701263351,1624326.672782828],[-10128691.574524524,1623447.6469578946],[-10128825.339324169,1622482.6294750923],[-10128051.414412405,1622367.9739328555],[-10127344.371900463,1621125.8722229986],[-10127554.573728638,1619683.1233138966],[-10127048.178416166,1617084.2643527186]]},"properties":null}]},"clipToAoi":true,"grid":{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1562984.3540953137],[-10189973.112928126,1565430.3390000015],[-10187527.128023438,1565430.3390000015],[-10187527.128023438,1562984.3540953137],[-10189973.112928126,1562984.3540953137]]]]},"properties":{"x":4026,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1565430.3390000015],[-10189973.112928126,1567876.3239046894],[-10187527.128023438,1567876.3239046894],[-10187527.128023438,1565430.3390000015],[-10189973.112928126,1565430.3390000015]]]]},"properties":{"x":4026,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1567876.3239046894],[-10189973.112928126,1570322.3088093735],[-10187527.128023438,1570322.3088093735],[-10187527.128023438,1567876.3239046894],[-10189973.112928126,1567876.3239046894]]]]},"properties":{"x":4026,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1570322.3088093735],[-10189973.112928126,1572768.2937140614],[-10187527.128023438,1572768.2937140614],[-10187527.128023438,1570322.3088093735],[-10189973.112928126,1570322.3088093735]]]]},"properties":{"x":4026,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1572768.2937140614],[-10189973.112928126,1575214.2786187492],[-10187527.128023438,1575214.2786187492],[-10187527.128023438,1572768.2937140614],[-10189973.112928126,1572768.2937140614]]]]},"properties":{"x":4026,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1575214.2786187492],[-10189973.112928126,1577660.263523437],[-10187527.128023438,1577660.263523437],[-10187527.128023438,1575214.2786187492],[-10189973.112928126,1575214.2786187492]]]]},"properties":{"x":4026,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1577660.263523437],[-10189973.112928126,1580106.248428125],[-10187527.128023438,1580106.248428125],[-10187527.128023438,1577660.263523437],[-10189973.112928126,1577660.263523437]]]]},"properties":{"x":4026,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1580106.248428125],[-10189973.112928126,1582552.2333328128],[-10187527.128023438,1582552.2333328128],[-10187527.128023438,1580106.248428125],[-10189973.112928126,1580106.248428125]]]]},"properties":{"x":4026,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1582552.2333328128],[-10189973.112928126,1584998.2182375006],[-10187527.128023438,1584998.2182375006],[-10187527.128023438,1582552.2333328128],[-10189973.112928126,1582552.2333328128]]]]},"properties":{"x":4026,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1584998.2182375006],[-10189973.112928126,1587444.2031421885],[-10187527.128023438,1587444.2031421885],[-10187527.128023438,1584998.2182375006],[-10189973.112928126,1584998.2182375006]]]]},"properties":{"x":4026,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1587444.2031421885],[-10189973.112928126,1589890.1880468763],[-10187527.128023438,1589890.1880468763],[-10187527.128023438,1587444.2031421885],[-10189973.112928126,1587444.2031421885]]]]},"properties":{"x":4026,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1589890.1880468763],[-10189973.112928126,1592336.1729515642],[-10187527.128023438,1592336.1729515642],[-10187527.128023438,1589890.1880468763],[-10189973.112928126,1589890.1880468763]]]]},"properties":{"x":4026,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1592336.1729515642],[-10189973.112928126,1594782.1578562483],[-10187527.128023438,1594782.1578562483],[-10187527.128023438,1592336.1729515642],[-10189973.112928126,1592336.1729515642]]]]},"properties":{"x":4026,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1594782.1578562483],[-10189973.112928126,1597228.1427609362],[-10187527.128023438,1597228.1427609362],[-10187527.128023438,1594782.1578562483],[-10189973.112928126,1594782.1578562483]]]]},"properties":{"x":4026,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1597228.1427609362],[-10189973.112928126,1599674.127665624],[-10187527.128023438,1599674.127665624],[-10187527.128023438,1597228.1427609362],[-10189973.112928126,1597228.1427609362]]]]},"properties":{"x":4026,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1599674.127665624],[-10189973.112928126,1602120.1125703119],[-10187527.128023438,1602120.1125703119],[-10187527.128023438,1599674.127665624],[-10189973.112928126,1599674.127665624]]]]},"properties":{"x":4026,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1602120.1125703119],[-10189973.112928126,1604566.0974749997],[-10187527.128023438,1604566.0974749997],[-10187527.128023438,1602120.1125703119],[-10189973.112928126,1602120.1125703119]]]]},"properties":{"x":4026,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1604566.0974749997],[-10189973.112928126,1607012.0823796876],[-10187527.128023438,1607012.0823796876],[-10187527.128023438,1604566.0974749997],[-10189973.112928126,1604566.0974749997]]]]},"properties":{"x":4026,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1607012.0823796876],[-10189973.112928126,1609458.0672843754],[-10187527.128023438,1609458.0672843754],[-10187527.128023438,1607012.0823796876],[-10189973.112928126,1607012.0823796876]]]]},"properties":{"x":4026,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1609458.0672843754],[-10189973.112928126,1611904.0521890633],[-10187527.128023438,1611904.0521890633],[-10187527.128023438,1609458.0672843754],[-10189973.112928126,1609458.0672843754]]]]},"properties":{"x":4026,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1611904.0521890633],[-10189973.112928126,1614350.0370937511],[-10187527.128023438,1614350.0370937511],[-10187527.128023438,1611904.0521890633],[-10189973.112928126,1611904.0521890633]]]]},"properties":{"x":4026,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1614350.0370937511],[-10189973.112928126,1616796.021998439],[-10187527.128023438,1616796.021998439],[-10187527.128023438,1614350.0370937511],[-10189973.112928126,1614350.0370937511]]]]},"properties":{"x":4026,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1616796.021998439],[-10189973.112928126,1619242.0069031268],[-10187527.128023438,1619242.0069031268],[-10187527.128023438,1616796.021998439],[-10189973.112928126,1616796.021998439]]]]},"properties":{"x":4026,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1619242.0069031268],[-10189973.112928126,1621687.991807811],[-10187527.128023438,1621687.991807811],[-10187527.128023438,1619242.0069031268],[-10189973.112928126,1619242.0069031268]]]]},"properties":{"x":4026,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1621687.991807811],[-10189973.112928126,1624133.9767124988],[-10187527.128023438,1624133.9767124988],[-10187527.128023438,1621687.991807811],[-10189973.112928126,1621687.991807811]]]]},"properties":{"x":4026,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10189973.112928126,1624133.9767124988],[-10189973.112928126,1626579.9616171867],[-10187527.128023438,1626579.9616171867],[-10187527.128023438,1624133.9767124988],[-10189973.112928126,1624133.9767124988]]]]},"properties":{"x":4026,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1562984.3540953137],[-10187527.128023438,1565430.3390000015],[-10185081.14311875,1565430.3390000015],[-10185081.14311875,1562984.3540953137],[-10187527.128023438,1562984.3540953137]]]]},"properties":{"x":4027,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1565430.3390000015],[-10187527.128023438,1567876.3239046894],[-10185081.14311875,1567876.3239046894],[-10185081.14311875,1565430.3390000015],[-10187527.128023438,1565430.3390000015]]]]},"properties":{"x":4027,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1567876.3239046894],[-10187527.128023438,1570322.3088093735],[-10185081.14311875,1570322.3088093735],[-10185081.14311875,1567876.3239046894],[-10187527.128023438,1567876.3239046894]]]]},"properties":{"x":4027,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1570322.3088093735],[-10187527.128023438,1572768.2937140614],[-10185081.14311875,1572768.2937140614],[-10185081.14311875,1570322.3088093735],[-10187527.128023438,1570322.3088093735]]]]},"properties":{"x":4027,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1572768.2937140614],[-10187527.128023438,1575214.2786187492],[-10185081.14311875,1575214.2786187492],[-10185081.14311875,1572768.2937140614],[-10187527.128023438,1572768.2937140614]]]]},"properties":{"x":4027,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1575214.2786187492],[-10187527.128023438,1577660.263523437],[-10185081.14311875,1577660.263523437],[-10185081.14311875,1575214.2786187492],[-10187527.128023438,1575214.2786187492]]]]},"properties":{"x":4027,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1577660.263523437],[-10187527.128023438,1580106.248428125],[-10185081.14311875,1580106.248428125],[-10185081.14311875,1577660.263523437],[-10187527.128023438,1577660.263523437]]]]},"properties":{"x":4027,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1580106.248428125],[-10187527.128023438,1582552.2333328128],[-10185081.14311875,1582552.2333328128],[-10185081.14311875,1580106.248428125],[-10187527.128023438,1580106.248428125]]]]},"properties":{"x":4027,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1582552.2333328128],[-10187527.128023438,1584998.2182375006],[-10185081.14311875,1584998.2182375006],[-10185081.14311875,1582552.2333328128],[-10187527.128023438,1582552.2333328128]]]]},"properties":{"x":4027,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1584998.2182375006],[-10187527.128023438,1587444.2031421885],[-10185081.14311875,1587444.2031421885],[-10185081.14311875,1584998.2182375006],[-10187527.128023438,1584998.2182375006]]]]},"properties":{"x":4027,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1587444.2031421885],[-10187527.128023438,1589890.1880468763],[-10185081.14311875,1589890.1880468763],[-10185081.14311875,1587444.2031421885],[-10187527.128023438,1587444.2031421885]]]]},"properties":{"x":4027,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1589890.1880468763],[-10187527.128023438,1592336.1729515642],[-10185081.14311875,1592336.1729515642],[-10185081.14311875,1589890.1880468763],[-10187527.128023438,1589890.1880468763]]]]},"properties":{"x":4027,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1592336.1729515642],[-10187527.128023438,1594782.1578562483],[-10185081.14311875,1594782.1578562483],[-10185081.14311875,1592336.1729515642],[-10187527.128023438,1592336.1729515642]]]]},"properties":{"x":4027,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1594782.1578562483],[-10187527.128023438,1597228.1427609362],[-10185081.14311875,1597228.1427609362],[-10185081.14311875,1594782.1578562483],[-10187527.128023438,1594782.1578562483]]]]},"properties":{"x":4027,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1597228.1427609362],[-10187527.128023438,1599674.127665624],[-10185081.14311875,1599674.127665624],[-10185081.14311875,1597228.1427609362],[-10187527.128023438,1597228.1427609362]]]]},"properties":{"x":4027,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1599674.127665624],[-10187527.128023438,1602120.1125703119],[-10185081.14311875,1602120.1125703119],[-10185081.14311875,1599674.127665624],[-10187527.128023438,1599674.127665624]]]]},"properties":{"x":4027,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1602120.1125703119],[-10187527.128023438,1604566.0974749997],[-10185081.14311875,1604566.0974749997],[-10185081.14311875,1602120.1125703119],[-10187527.128023438,1602120.1125703119]]]]},"properties":{"x":4027,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1604566.0974749997],[-10187527.128023438,1607012.0823796876],[-10185081.14311875,1607012.0823796876],[-10185081.14311875,1604566.0974749997],[-10187527.128023438,1604566.0974749997]]]]},"properties":{"x":4027,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1607012.0823796876],[-10187527.128023438,1609458.0672843754],[-10185081.14311875,1609458.0672843754],[-10185081.14311875,1607012.0823796876],[-10187527.128023438,1607012.0823796876]]]]},"properties":{"x":4027,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1609458.0672843754],[-10187527.128023438,1611904.0521890633],[-10185081.14311875,1611904.0521890633],[-10185081.14311875,1609458.0672843754],[-10187527.128023438,1609458.0672843754]]]]},"properties":{"x":4027,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1611904.0521890633],[-10187527.128023438,1614350.0370937511],[-10185081.14311875,1614350.0370937511],[-10185081.14311875,1611904.0521890633],[-10187527.128023438,1611904.0521890633]]]]},"properties":{"x":4027,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1614350.0370937511],[-10187527.128023438,1616796.021998439],[-10185081.14311875,1616796.021998439],[-10185081.14311875,1614350.0370937511],[-10187527.128023438,1614350.0370937511]]]]},"properties":{"x":4027,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1616796.021998439],[-10187527.128023438,1619242.0069031268],[-10185081.14311875,1619242.0069031268],[-10185081.14311875,1616796.021998439],[-10187527.128023438,1616796.021998439]]]]},"properties":{"x":4027,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1619242.0069031268],[-10187527.128023438,1621687.991807811],[-10185081.14311875,1621687.991807811],[-10185081.14311875,1619242.0069031268],[-10187527.128023438,1619242.0069031268]]]]},"properties":{"x":4027,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1621687.991807811],[-10187527.128023438,1624133.9767124988],[-10185081.14311875,1624133.9767124988],[-10185081.14311875,1621687.991807811],[-10187527.128023438,1621687.991807811]]]]},"properties":{"x":4027,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10187527.128023438,1624133.9767124988],[-10187527.128023438,1626579.9616171867],[-10185081.14311875,1626579.9616171867],[-10185081.14311875,1624133.9767124988],[-10187527.128023438,1624133.9767124988]]]]},"properties":{"x":4027,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1562984.3540953137],[-10185081.14311875,1565430.3390000015],[-10182635.158214062,1565430.3390000015],[-10182635.158214062,1562984.3540953137],[-10185081.14311875,1562984.3540953137]]]]},"properties":{"x":4028,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1565430.3390000015],[-10185081.14311875,1567876.3239046894],[-10182635.158214062,1567876.3239046894],[-10182635.158214062,1565430.3390000015],[-10185081.14311875,1565430.3390000015]]]]},"properties":{"x":4028,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1567876.3239046894],[-10185081.14311875,1570322.3088093735],[-10182635.158214062,1570322.3088093735],[-10182635.158214062,1567876.3239046894],[-10185081.14311875,1567876.3239046894]]]]},"properties":{"x":4028,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1570322.3088093735],[-10185081.14311875,1572768.2937140614],[-10182635.158214062,1572768.2937140614],[-10182635.158214062,1570322.3088093735],[-10185081.14311875,1570322.3088093735]]]]},"properties":{"x":4028,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1572768.2937140614],[-10185081.14311875,1575214.2786187492],[-10182635.158214062,1575214.2786187492],[-10182635.158214062,1572768.2937140614],[-10185081.14311875,1572768.2937140614]]]]},"properties":{"x":4028,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1575214.2786187492],[-10185081.14311875,1577660.263523437],[-10182635.158214062,1577660.263523437],[-10182635.158214062,1575214.2786187492],[-10185081.14311875,1575214.2786187492]]]]},"properties":{"x":4028,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1577660.263523437],[-10185081.14311875,1580106.248428125],[-10182635.158214062,1580106.248428125],[-10182635.158214062,1577660.263523437],[-10185081.14311875,1577660.263523437]]]]},"properties":{"x":4028,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1580106.248428125],[-10185081.14311875,1582552.2333328128],[-10182635.158214062,1582552.2333328128],[-10182635.158214062,1580106.248428125],[-10185081.14311875,1580106.248428125]]]]},"properties":{"x":4028,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1582552.2333328128],[-10185081.14311875,1584998.2182375006],[-10182635.158214062,1584998.2182375006],[-10182635.158214062,1582552.2333328128],[-10185081.14311875,1582552.2333328128]]]]},"properties":{"x":4028,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1584998.2182375006],[-10185081.14311875,1587444.2031421885],[-10182635.158214062,1587444.2031421885],[-10182635.158214062,1584998.2182375006],[-10185081.14311875,1584998.2182375006]]]]},"properties":{"x":4028,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1587444.2031421885],[-10185081.14311875,1589890.1880468763],[-10182635.158214062,1589890.1880468763],[-10182635.158214062,1587444.2031421885],[-10185081.14311875,1587444.2031421885]]]]},"properties":{"x":4028,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1589890.1880468763],[-10185081.14311875,1592336.1729515642],[-10182635.158214062,1592336.1729515642],[-10182635.158214062,1589890.1880468763],[-10185081.14311875,1589890.1880468763]]]]},"properties":{"x":4028,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1592336.1729515642],[-10185081.14311875,1594782.1578562483],[-10182635.158214062,1594782.1578562483],[-10182635.158214062,1592336.1729515642],[-10185081.14311875,1592336.1729515642]]]]},"properties":{"x":4028,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1594782.1578562483],[-10185081.14311875,1597228.1427609362],[-10182635.158214062,1597228.1427609362],[-10182635.158214062,1594782.1578562483],[-10185081.14311875,1594782.1578562483]]]]},"properties":{"x":4028,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1597228.1427609362],[-10185081.14311875,1599674.127665624],[-10182635.158214062,1599674.127665624],[-10182635.158214062,1597228.1427609362],[-10185081.14311875,1597228.1427609362]]]]},"properties":{"x":4028,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1599674.127665624],[-10185081.14311875,1602120.1125703119],[-10182635.158214062,1602120.1125703119],[-10182635.158214062,1599674.127665624],[-10185081.14311875,1599674.127665624]]]]},"properties":{"x":4028,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1602120.1125703119],[-10185081.14311875,1604566.0974749997],[-10182635.158214062,1604566.0974749997],[-10182635.158214062,1602120.1125703119],[-10185081.14311875,1602120.1125703119]]]]},"properties":{"x":4028,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1604566.0974749997],[-10185081.14311875,1607012.0823796876],[-10182635.158214062,1607012.0823796876],[-10182635.158214062,1604566.0974749997],[-10185081.14311875,1604566.0974749997]]]]},"properties":{"x":4028,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1607012.0823796876],[-10185081.14311875,1609458.0672843754],[-10182635.158214062,1609458.0672843754],[-10182635.158214062,1607012.0823796876],[-10185081.14311875,1607012.0823796876]]]]},"properties":{"x":4028,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1609458.0672843754],[-10185081.14311875,1611904.0521890633],[-10182635.158214062,1611904.0521890633],[-10182635.158214062,1609458.0672843754],[-10185081.14311875,1609458.0672843754]]]]},"properties":{"x":4028,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1611904.0521890633],[-10185081.14311875,1614350.0370937511],[-10182635.158214062,1614350.0370937511],[-10182635.158214062,1611904.0521890633],[-10185081.14311875,1611904.0521890633]]]]},"properties":{"x":4028,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1614350.0370937511],[-10185081.14311875,1616796.021998439],[-10182635.158214062,1616796.021998439],[-10182635.158214062,1614350.0370937511],[-10185081.14311875,1614350.0370937511]]]]},"properties":{"x":4028,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1616796.021998439],[-10185081.14311875,1619242.0069031268],[-10182635.158214062,1619242.0069031268],[-10182635.158214062,1616796.021998439],[-10185081.14311875,1616796.021998439]]]]},"properties":{"x":4028,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1619242.0069031268],[-10185081.14311875,1621687.991807811],[-10182635.158214062,1621687.991807811],[-10182635.158214062,1619242.0069031268],[-10185081.14311875,1619242.0069031268]]]]},"properties":{"x":4028,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1621687.991807811],[-10185081.14311875,1624133.9767124988],[-10182635.158214062,1624133.9767124988],[-10182635.158214062,1621687.991807811],[-10185081.14311875,1621687.991807811]]]]},"properties":{"x":4028,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10185081.14311875,1624133.9767124988],[-10185081.14311875,1626579.9616171867],[-10182635.158214062,1626579.9616171867],[-10182635.158214062,1624133.9767124988],[-10185081.14311875,1624133.9767124988]]]]},"properties":{"x":4028,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1562984.3540953137],[-10182635.158214062,1565430.3390000015],[-10180189.173309376,1565430.3390000015],[-10180189.173309376,1562984.3540953137],[-10182635.158214062,1562984.3540953137]]]]},"properties":{"x":4029,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1565430.3390000015],[-10182635.158214062,1567876.3239046894],[-10180189.173309376,1567876.3239046894],[-10180189.173309376,1565430.3390000015],[-10182635.158214062,1565430.3390000015]]]]},"properties":{"x":4029,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1567876.3239046894],[-10182635.158214062,1570322.3088093735],[-10180189.173309376,1570322.3088093735],[-10180189.173309376,1567876.3239046894],[-10182635.158214062,1567876.3239046894]]]]},"properties":{"x":4029,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1570322.3088093735],[-10182635.158214062,1572768.2937140614],[-10180189.173309376,1572768.2937140614],[-10180189.173309376,1570322.3088093735],[-10182635.158214062,1570322.3088093735]]]]},"properties":{"x":4029,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1572768.2937140614],[-10182635.158214062,1575214.2786187492],[-10180189.173309376,1575214.2786187492],[-10180189.173309376,1572768.2937140614],[-10182635.158214062,1572768.2937140614]]]]},"properties":{"x":4029,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1575214.2786187492],[-10182635.158214062,1577660.263523437],[-10180189.173309376,1577660.263523437],[-10180189.173309376,1575214.2786187492],[-10182635.158214062,1575214.2786187492]]]]},"properties":{"x":4029,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1577660.263523437],[-10182635.158214062,1580106.248428125],[-10180189.173309376,1580106.248428125],[-10180189.173309376,1577660.263523437],[-10182635.158214062,1577660.263523437]]]]},"properties":{"x":4029,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1580106.248428125],[-10182635.158214062,1582552.2333328128],[-10180189.173309376,1582552.2333328128],[-10180189.173309376,1580106.248428125],[-10182635.158214062,1580106.248428125]]]]},"properties":{"x":4029,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1582552.2333328128],[-10182635.158214062,1584998.2182375006],[-10180189.173309376,1584998.2182375006],[-10180189.173309376,1582552.2333328128],[-10182635.158214062,1582552.2333328128]]]]},"properties":{"x":4029,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1584998.2182375006],[-10182635.158214062,1587444.2031421885],[-10180189.173309376,1587444.2031421885],[-10180189.173309376,1584998.2182375006],[-10182635.158214062,1584998.2182375006]]]]},"properties":{"x":4029,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1587444.2031421885],[-10182635.158214062,1589890.1880468763],[-10180189.173309376,1589890.1880468763],[-10180189.173309376,1587444.2031421885],[-10182635.158214062,1587444.2031421885]]]]},"properties":{"x":4029,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1589890.1880468763],[-10182635.158214062,1592336.1729515642],[-10180189.173309376,1592336.1729515642],[-10180189.173309376,1589890.1880468763],[-10182635.158214062,1589890.1880468763]]]]},"properties":{"x":4029,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1592336.1729515642],[-10182635.158214062,1594782.1578562483],[-10180189.173309376,1594782.1578562483],[-10180189.173309376,1592336.1729515642],[-10182635.158214062,1592336.1729515642]]]]},"properties":{"x":4029,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1594782.1578562483],[-10182635.158214062,1597228.1427609362],[-10180189.173309376,1597228.1427609362],[-10180189.173309376,1594782.1578562483],[-10182635.158214062,1594782.1578562483]]]]},"properties":{"x":4029,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1597228.1427609362],[-10182635.158214062,1599674.127665624],[-10180189.173309376,1599674.127665624],[-10180189.173309376,1597228.1427609362],[-10182635.158214062,1597228.1427609362]]]]},"properties":{"x":4029,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1599674.127665624],[-10182635.158214062,1602120.1125703119],[-10180189.173309376,1602120.1125703119],[-10180189.173309376,1599674.127665624],[-10182635.158214062,1599674.127665624]]]]},"properties":{"x":4029,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1602120.1125703119],[-10182635.158214062,1604566.0974749997],[-10180189.173309376,1604566.0974749997],[-10180189.173309376,1602120.1125703119],[-10182635.158214062,1602120.1125703119]]]]},"properties":{"x":4029,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1604566.0974749997],[-10182635.158214062,1607012.0823796876],[-10180189.173309376,1607012.0823796876],[-10180189.173309376,1604566.0974749997],[-10182635.158214062,1604566.0974749997]]]]},"properties":{"x":4029,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1607012.0823796876],[-10182635.158214062,1609458.0672843754],[-10180189.173309376,1609458.0672843754],[-10180189.173309376,1607012.0823796876],[-10182635.158214062,1607012.0823796876]]]]},"properties":{"x":4029,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1609458.0672843754],[-10182635.158214062,1611904.0521890633],[-10180189.173309376,1611904.0521890633],[-10180189.173309376,1609458.0672843754],[-10182635.158214062,1609458.0672843754]]]]},"properties":{"x":4029,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1611904.0521890633],[-10182635.158214062,1614350.0370937511],[-10180189.173309376,1614350.0370937511],[-10180189.173309376,1611904.0521890633],[-10182635.158214062,1611904.0521890633]]]]},"properties":{"x":4029,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1614350.0370937511],[-10182635.158214062,1616796.021998439],[-10180189.173309376,1616796.021998439],[-10180189.173309376,1614350.0370937511],[-10182635.158214062,1614350.0370937511]]]]},"properties":{"x":4029,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1616796.021998439],[-10182635.158214062,1619242.0069031268],[-10180189.173309376,1619242.0069031268],[-10180189.173309376,1616796.021998439],[-10182635.158214062,1616796.021998439]]]]},"properties":{"x":4029,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1619242.0069031268],[-10182635.158214062,1621687.991807811],[-10180189.173309376,1621687.991807811],[-10180189.173309376,1619242.0069031268],[-10182635.158214062,1619242.0069031268]]]]},"properties":{"x":4029,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1621687.991807811],[-10182635.158214062,1624133.9767124988],[-10180189.173309376,1624133.9767124988],[-10180189.173309376,1621687.991807811],[-10182635.158214062,1621687.991807811]]]]},"properties":{"x":4029,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10182635.158214062,1624133.9767124988],[-10182635.158214062,1626579.9616171867],[-10180189.173309376,1626579.9616171867],[-10180189.173309376,1624133.9767124988],[-10182635.158214062,1624133.9767124988]]]]},"properties":{"x":4029,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1562984.3540953137],[-10180189.173309376,1565430.3390000015],[-10177743.188404689,1565430.3390000015],[-10177743.188404689,1562984.3540953137],[-10180189.173309376,1562984.3540953137]]]]},"properties":{"x":4030,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1565430.3390000015],[-10180189.173309376,1567876.3239046894],[-10177743.188404689,1567876.3239046894],[-10177743.188404689,1565430.3390000015],[-10180189.173309376,1565430.3390000015]]]]},"properties":{"x":4030,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1567876.3239046894],[-10180189.173309376,1570322.3088093735],[-10177743.188404689,1570322.3088093735],[-10177743.188404689,1567876.3239046894],[-10180189.173309376,1567876.3239046894]]]]},"properties":{"x":4030,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1570322.3088093735],[-10180189.173309376,1572768.2937140614],[-10177743.188404689,1572768.2937140614],[-10177743.188404689,1570322.3088093735],[-10180189.173309376,1570322.3088093735]]]]},"properties":{"x":4030,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1572768.2937140614],[-10180189.173309376,1575214.2786187492],[-10177743.188404689,1575214.2786187492],[-10177743.188404689,1572768.2937140614],[-10180189.173309376,1572768.2937140614]]]]},"properties":{"x":4030,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1575214.2786187492],[-10180189.173309376,1577660.263523437],[-10177743.188404689,1577660.263523437],[-10177743.188404689,1575214.2786187492],[-10180189.173309376,1575214.2786187492]]]]},"properties":{"x":4030,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1577660.263523437],[-10180189.173309376,1580106.248428125],[-10177743.188404689,1580106.248428125],[-10177743.188404689,1577660.263523437],[-10180189.173309376,1577660.263523437]]]]},"properties":{"x":4030,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1580106.248428125],[-10180189.173309376,1582552.2333328128],[-10177743.188404689,1582552.2333328128],[-10177743.188404689,1580106.248428125],[-10180189.173309376,1580106.248428125]]]]},"properties":{"x":4030,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1582552.2333328128],[-10180189.173309376,1584998.2182375006],[-10177743.188404689,1584998.2182375006],[-10177743.188404689,1582552.2333328128],[-10180189.173309376,1582552.2333328128]]]]},"properties":{"x":4030,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1584998.2182375006],[-10180189.173309376,1587444.2031421885],[-10177743.188404689,1587444.2031421885],[-10177743.188404689,1584998.2182375006],[-10180189.173309376,1584998.2182375006]]]]},"properties":{"x":4030,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1587444.2031421885],[-10180189.173309376,1589890.1880468763],[-10177743.188404689,1589890.1880468763],[-10177743.188404689,1587444.2031421885],[-10180189.173309376,1587444.2031421885]]]]},"properties":{"x":4030,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1589890.1880468763],[-10180189.173309376,1592336.1729515642],[-10177743.188404689,1592336.1729515642],[-10177743.188404689,1589890.1880468763],[-10180189.173309376,1589890.1880468763]]]]},"properties":{"x":4030,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1592336.1729515642],[-10180189.173309376,1594782.1578562483],[-10177743.188404689,1594782.1578562483],[-10177743.188404689,1592336.1729515642],[-10180189.173309376,1592336.1729515642]]]]},"properties":{"x":4030,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1594782.1578562483],[-10180189.173309376,1597228.1427609362],[-10177743.188404689,1597228.1427609362],[-10177743.188404689,1594782.1578562483],[-10180189.173309376,1594782.1578562483]]]]},"properties":{"x":4030,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1597228.1427609362],[-10180189.173309376,1599674.127665624],[-10177743.188404689,1599674.127665624],[-10177743.188404689,1597228.1427609362],[-10180189.173309376,1597228.1427609362]]]]},"properties":{"x":4030,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1599674.127665624],[-10180189.173309376,1602120.1125703119],[-10177743.188404689,1602120.1125703119],[-10177743.188404689,1599674.127665624],[-10180189.173309376,1599674.127665624]]]]},"properties":{"x":4030,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1602120.1125703119],[-10180189.173309376,1604566.0974749997],[-10177743.188404689,1604566.0974749997],[-10177743.188404689,1602120.1125703119],[-10180189.173309376,1602120.1125703119]]]]},"properties":{"x":4030,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1604566.0974749997],[-10180189.173309376,1607012.0823796876],[-10177743.188404689,1607012.0823796876],[-10177743.188404689,1604566.0974749997],[-10180189.173309376,1604566.0974749997]]]]},"properties":{"x":4030,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1607012.0823796876],[-10180189.173309376,1609458.0672843754],[-10177743.188404689,1609458.0672843754],[-10177743.188404689,1607012.0823796876],[-10180189.173309376,1607012.0823796876]]]]},"properties":{"x":4030,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1609458.0672843754],[-10180189.173309376,1611904.0521890633],[-10177743.188404689,1611904.0521890633],[-10177743.188404689,1609458.0672843754],[-10180189.173309376,1609458.0672843754]]]]},"properties":{"x":4030,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1611904.0521890633],[-10180189.173309376,1614350.0370937511],[-10177743.188404689,1614350.0370937511],[-10177743.188404689,1611904.0521890633],[-10180189.173309376,1611904.0521890633]]]]},"properties":{"x":4030,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1614350.0370937511],[-10180189.173309376,1616796.021998439],[-10177743.188404689,1616796.021998439],[-10177743.188404689,1614350.0370937511],[-10180189.173309376,1614350.0370937511]]]]},"properties":{"x":4030,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1616796.021998439],[-10180189.173309376,1619242.0069031268],[-10177743.188404689,1619242.0069031268],[-10177743.188404689,1616796.021998439],[-10180189.173309376,1616796.021998439]]]]},"properties":{"x":4030,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1619242.0069031268],[-10180189.173309376,1621687.991807811],[-10177743.188404689,1621687.991807811],[-10177743.188404689,1619242.0069031268],[-10180189.173309376,1619242.0069031268]]]]},"properties":{"x":4030,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1621687.991807811],[-10180189.173309376,1624133.9767124988],[-10177743.188404689,1624133.9767124988],[-10177743.188404689,1621687.991807811],[-10180189.173309376,1621687.991807811]]]]},"properties":{"x":4030,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10180189.173309376,1624133.9767124988],[-10180189.173309376,1626579.9616171867],[-10177743.188404689,1626579.9616171867],[-10177743.188404689,1624133.9767124988],[-10180189.173309376,1624133.9767124988]]]]},"properties":{"x":4030,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1562984.3540953137],[-10177743.188404689,1565430.3390000015],[-10175297.2035,1565430.3390000015],[-10175297.2035,1562984.3540953137],[-10177743.188404689,1562984.3540953137]]]]},"properties":{"x":4031,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1565430.3390000015],[-10177743.188404689,1567876.3239046894],[-10175297.2035,1567876.3239046894],[-10175297.2035,1565430.3390000015],[-10177743.188404689,1565430.3390000015]]]]},"properties":{"x":4031,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1567876.3239046894],[-10177743.188404689,1570322.3088093735],[-10175297.2035,1570322.3088093735],[-10175297.2035,1567876.3239046894],[-10177743.188404689,1567876.3239046894]]]]},"properties":{"x":4031,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1570322.3088093735],[-10177743.188404689,1572768.2937140614],[-10175297.2035,1572768.2937140614],[-10175297.2035,1570322.3088093735],[-10177743.188404689,1570322.3088093735]]]]},"properties":{"x":4031,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1572768.2937140614],[-10177743.188404689,1575214.2786187492],[-10175297.2035,1575214.2786187492],[-10175297.2035,1572768.2937140614],[-10177743.188404689,1572768.2937140614]]]]},"properties":{"x":4031,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1575214.2786187492],[-10177743.188404689,1577660.263523437],[-10175297.2035,1577660.263523437],[-10175297.2035,1575214.2786187492],[-10177743.188404689,1575214.2786187492]]]]},"properties":{"x":4031,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1577660.263523437],[-10177743.188404689,1580106.248428125],[-10175297.2035,1580106.248428125],[-10175297.2035,1577660.263523437],[-10177743.188404689,1577660.263523437]]]]},"properties":{"x":4031,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1580106.248428125],[-10177743.188404689,1582552.2333328128],[-10175297.2035,1582552.2333328128],[-10175297.2035,1580106.248428125],[-10177743.188404689,1580106.248428125]]]]},"properties":{"x":4031,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1582552.2333328128],[-10177743.188404689,1584998.2182375006],[-10175297.2035,1584998.2182375006],[-10175297.2035,1582552.2333328128],[-10177743.188404689,1582552.2333328128]]]]},"properties":{"x":4031,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1584998.2182375006],[-10177743.188404689,1587444.2031421885],[-10175297.2035,1587444.2031421885],[-10175297.2035,1584998.2182375006],[-10177743.188404689,1584998.2182375006]]]]},"properties":{"x":4031,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1587444.2031421885],[-10177743.188404689,1589890.1880468763],[-10175297.2035,1589890.1880468763],[-10175297.2035,1587444.2031421885],[-10177743.188404689,1587444.2031421885]]]]},"properties":{"x":4031,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1589890.1880468763],[-10177743.188404689,1592336.1729515642],[-10175297.2035,1592336.1729515642],[-10175297.2035,1589890.1880468763],[-10177743.188404689,1589890.1880468763]]]]},"properties":{"x":4031,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1592336.1729515642],[-10177743.188404689,1594782.1578562483],[-10175297.2035,1594782.1578562483],[-10175297.2035,1592336.1729515642],[-10177743.188404689,1592336.1729515642]]]]},"properties":{"x":4031,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1594782.1578562483],[-10177743.188404689,1597228.1427609362],[-10175297.2035,1597228.1427609362],[-10175297.2035,1594782.1578562483],[-10177743.188404689,1594782.1578562483]]]]},"properties":{"x":4031,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1597228.1427609362],[-10177743.188404689,1599674.127665624],[-10175297.2035,1599674.127665624],[-10175297.2035,1597228.1427609362],[-10177743.188404689,1597228.1427609362]]]]},"properties":{"x":4031,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1599674.127665624],[-10177743.188404689,1602120.1125703119],[-10175297.2035,1602120.1125703119],[-10175297.2035,1599674.127665624],[-10177743.188404689,1599674.127665624]]]]},"properties":{"x":4031,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1602120.1125703119],[-10177743.188404689,1604566.0974749997],[-10175297.2035,1604566.0974749997],[-10175297.2035,1602120.1125703119],[-10177743.188404689,1602120.1125703119]]]]},"properties":{"x":4031,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1604566.0974749997],[-10177743.188404689,1607012.0823796876],[-10175297.2035,1607012.0823796876],[-10175297.2035,1604566.0974749997],[-10177743.188404689,1604566.0974749997]]]]},"properties":{"x":4031,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1607012.0823796876],[-10177743.188404689,1609458.0672843754],[-10175297.2035,1609458.0672843754],[-10175297.2035,1607012.0823796876],[-10177743.188404689,1607012.0823796876]]]]},"properties":{"x":4031,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1609458.0672843754],[-10177743.188404689,1611904.0521890633],[-10175297.2035,1611904.0521890633],[-10175297.2035,1609458.0672843754],[-10177743.188404689,1609458.0672843754]]]]},"properties":{"x":4031,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1611904.0521890633],[-10177743.188404689,1614350.0370937511],[-10175297.2035,1614350.0370937511],[-10175297.2035,1611904.0521890633],[-10177743.188404689,1611904.0521890633]]]]},"properties":{"x":4031,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1614350.0370937511],[-10177743.188404689,1616796.021998439],[-10175297.2035,1616796.021998439],[-10175297.2035,1614350.0370937511],[-10177743.188404689,1614350.0370937511]]]]},"properties":{"x":4031,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1616796.021998439],[-10177743.188404689,1619242.0069031268],[-10175297.2035,1619242.0069031268],[-10175297.2035,1616796.021998439],[-10177743.188404689,1616796.021998439]]]]},"properties":{"x":4031,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1619242.0069031268],[-10177743.188404689,1621687.991807811],[-10175297.2035,1621687.991807811],[-10175297.2035,1619242.0069031268],[-10177743.188404689,1619242.0069031268]]]]},"properties":{"x":4031,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1621687.991807811],[-10177743.188404689,1624133.9767124988],[-10175297.2035,1624133.9767124988],[-10175297.2035,1621687.991807811],[-10177743.188404689,1621687.991807811]]]]},"properties":{"x":4031,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10177743.188404689,1624133.9767124988],[-10177743.188404689,1626579.9616171867],[-10175297.2035,1626579.9616171867],[-10175297.2035,1624133.9767124988],[-10177743.188404689,1624133.9767124988]]]]},"properties":{"x":4031,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1562984.3540953137],[-10175297.2035,1565430.3390000015],[-10172851.218595313,1565430.3390000015],[-10172851.218595313,1562984.3540953137],[-10175297.2035,1562984.3540953137]]]]},"properties":{"x":4032,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1565430.3390000015],[-10175297.2035,1567876.3239046894],[-10172851.218595313,1567876.3239046894],[-10172851.218595313,1565430.3390000015],[-10175297.2035,1565430.3390000015]]]]},"properties":{"x":4032,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1567876.3239046894],[-10175297.2035,1570322.3088093735],[-10172851.218595313,1570322.3088093735],[-10172851.218595313,1567876.3239046894],[-10175297.2035,1567876.3239046894]]]]},"properties":{"x":4032,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1570322.3088093735],[-10175297.2035,1572768.2937140614],[-10172851.218595313,1572768.2937140614],[-10172851.218595313,1570322.3088093735],[-10175297.2035,1570322.3088093735]]]]},"properties":{"x":4032,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1572768.2937140614],[-10175297.2035,1575214.2786187492],[-10172851.218595313,1575214.2786187492],[-10172851.218595313,1572768.2937140614],[-10175297.2035,1572768.2937140614]]]]},"properties":{"x":4032,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1575214.2786187492],[-10175297.2035,1577660.263523437],[-10172851.218595313,1577660.263523437],[-10172851.218595313,1575214.2786187492],[-10175297.2035,1575214.2786187492]]]]},"properties":{"x":4032,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1577660.263523437],[-10175297.2035,1580106.248428125],[-10172851.218595313,1580106.248428125],[-10172851.218595313,1577660.263523437],[-10175297.2035,1577660.263523437]]]]},"properties":{"x":4032,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1580106.248428125],[-10175297.2035,1582552.2333328128],[-10172851.218595313,1582552.2333328128],[-10172851.218595313,1580106.248428125],[-10175297.2035,1580106.248428125]]]]},"properties":{"x":4032,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1582552.2333328128],[-10175297.2035,1584998.2182375006],[-10172851.218595313,1584998.2182375006],[-10172851.218595313,1582552.2333328128],[-10175297.2035,1582552.2333328128]]]]},"properties":{"x":4032,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1584998.2182375006],[-10175297.2035,1587444.2031421885],[-10172851.218595313,1587444.2031421885],[-10172851.218595313,1584998.2182375006],[-10175297.2035,1584998.2182375006]]]]},"properties":{"x":4032,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1587444.2031421885],[-10175297.2035,1589890.1880468763],[-10172851.218595313,1589890.1880468763],[-10172851.218595313,1587444.2031421885],[-10175297.2035,1587444.2031421885]]]]},"properties":{"x":4032,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1589890.1880468763],[-10175297.2035,1592336.1729515642],[-10172851.218595313,1592336.1729515642],[-10172851.218595313,1589890.1880468763],[-10175297.2035,1589890.1880468763]]]]},"properties":{"x":4032,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1592336.1729515642],[-10175297.2035,1594782.1578562483],[-10172851.218595313,1594782.1578562483],[-10172851.218595313,1592336.1729515642],[-10175297.2035,1592336.1729515642]]]]},"properties":{"x":4032,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1594782.1578562483],[-10175297.2035,1597228.1427609362],[-10172851.218595313,1597228.1427609362],[-10172851.218595313,1594782.1578562483],[-10175297.2035,1594782.1578562483]]]]},"properties":{"x":4032,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1597228.1427609362],[-10175297.2035,1599674.127665624],[-10172851.218595313,1599674.127665624],[-10172851.218595313,1597228.1427609362],[-10175297.2035,1597228.1427609362]]]]},"properties":{"x":4032,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1599674.127665624],[-10175297.2035,1602120.1125703119],[-10172851.218595313,1602120.1125703119],[-10172851.218595313,1599674.127665624],[-10175297.2035,1599674.127665624]]]]},"properties":{"x":4032,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1602120.1125703119],[-10175297.2035,1604566.0974749997],[-10172851.218595313,1604566.0974749997],[-10172851.218595313,1602120.1125703119],[-10175297.2035,1602120.1125703119]]]]},"properties":{"x":4032,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1604566.0974749997],[-10175297.2035,1607012.0823796876],[-10172851.218595313,1607012.0823796876],[-10172851.218595313,1604566.0974749997],[-10175297.2035,1604566.0974749997]]]]},"properties":{"x":4032,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1607012.0823796876],[-10175297.2035,1609458.0672843754],[-10172851.218595313,1609458.0672843754],[-10172851.218595313,1607012.0823796876],[-10175297.2035,1607012.0823796876]]]]},"properties":{"x":4032,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1609458.0672843754],[-10175297.2035,1611904.0521890633],[-10172851.218595313,1611904.0521890633],[-10172851.218595313,1609458.0672843754],[-10175297.2035,1609458.0672843754]]]]},"properties":{"x":4032,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1611904.0521890633],[-10175297.2035,1614350.0370937511],[-10172851.218595313,1614350.0370937511],[-10172851.218595313,1611904.0521890633],[-10175297.2035,1611904.0521890633]]]]},"properties":{"x":4032,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1614350.0370937511],[-10175297.2035,1616796.021998439],[-10172851.218595313,1616796.021998439],[-10172851.218595313,1614350.0370937511],[-10175297.2035,1614350.0370937511]]]]},"properties":{"x":4032,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1616796.021998439],[-10175297.2035,1619242.0069031268],[-10172851.218595313,1619242.0069031268],[-10172851.218595313,1616796.021998439],[-10175297.2035,1616796.021998439]]]]},"properties":{"x":4032,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1619242.0069031268],[-10175297.2035,1621687.991807811],[-10172851.218595313,1621687.991807811],[-10172851.218595313,1619242.0069031268],[-10175297.2035,1619242.0069031268]]]]},"properties":{"x":4032,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1621687.991807811],[-10175297.2035,1624133.9767124988],[-10172851.218595313,1624133.9767124988],[-10172851.218595313,1621687.991807811],[-10175297.2035,1621687.991807811]]]]},"properties":{"x":4032,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10175297.2035,1624133.9767124988],[-10175297.2035,1626579.9616171867],[-10172851.218595313,1626579.9616171867],[-10172851.218595313,1624133.9767124988],[-10175297.2035,1624133.9767124988]]]]},"properties":{"x":4032,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1562984.3540953137],[-10172851.218595313,1565430.3390000015],[-10170405.233690625,1565430.3390000015],[-10170405.233690625,1562984.3540953137],[-10172851.218595313,1562984.3540953137]]]]},"properties":{"x":4033,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1565430.3390000015],[-10172851.218595313,1567876.3239046894],[-10170405.233690625,1567876.3239046894],[-10170405.233690625,1565430.3390000015],[-10172851.218595313,1565430.3390000015]]]]},"properties":{"x":4033,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1567876.3239046894],[-10172851.218595313,1570322.3088093735],[-10170405.233690625,1570322.3088093735],[-10170405.233690625,1567876.3239046894],[-10172851.218595313,1567876.3239046894]]]]},"properties":{"x":4033,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1570322.3088093735],[-10172851.218595313,1572768.2937140614],[-10170405.233690625,1572768.2937140614],[-10170405.233690625,1570322.3088093735],[-10172851.218595313,1570322.3088093735]]]]},"properties":{"x":4033,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1572768.2937140614],[-10172851.218595313,1575214.2786187492],[-10170405.233690625,1575214.2786187492],[-10170405.233690625,1572768.2937140614],[-10172851.218595313,1572768.2937140614]]]]},"properties":{"x":4033,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1575214.2786187492],[-10172851.218595313,1577660.263523437],[-10170405.233690625,1577660.263523437],[-10170405.233690625,1575214.2786187492],[-10172851.218595313,1575214.2786187492]]]]},"properties":{"x":4033,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1577660.263523437],[-10172851.218595313,1580106.248428125],[-10170405.233690625,1580106.248428125],[-10170405.233690625,1577660.263523437],[-10172851.218595313,1577660.263523437]]]]},"properties":{"x":4033,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1580106.248428125],[-10172851.218595313,1582552.2333328128],[-10170405.233690625,1582552.2333328128],[-10170405.233690625,1580106.248428125],[-10172851.218595313,1580106.248428125]]]]},"properties":{"x":4033,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1582552.2333328128],[-10172851.218595313,1584998.2182375006],[-10170405.233690625,1584998.2182375006],[-10170405.233690625,1582552.2333328128],[-10172851.218595313,1582552.2333328128]]]]},"properties":{"x":4033,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1584998.2182375006],[-10172851.218595313,1587444.2031421885],[-10170405.233690625,1587444.2031421885],[-10170405.233690625,1584998.2182375006],[-10172851.218595313,1584998.2182375006]]]]},"properties":{"x":4033,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1587444.2031421885],[-10172851.218595313,1589890.1880468763],[-10170405.233690625,1589890.1880468763],[-10170405.233690625,1587444.2031421885],[-10172851.218595313,1587444.2031421885]]]]},"properties":{"x":4033,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1589890.1880468763],[-10172851.218595313,1592336.1729515642],[-10170405.233690625,1592336.1729515642],[-10170405.233690625,1589890.1880468763],[-10172851.218595313,1589890.1880468763]]]]},"properties":{"x":4033,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1592336.1729515642],[-10172851.218595313,1594782.1578562483],[-10170405.233690625,1594782.1578562483],[-10170405.233690625,1592336.1729515642],[-10172851.218595313,1592336.1729515642]]]]},"properties":{"x":4033,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1594782.1578562483],[-10172851.218595313,1597228.1427609362],[-10170405.233690625,1597228.1427609362],[-10170405.233690625,1594782.1578562483],[-10172851.218595313,1594782.1578562483]]]]},"properties":{"x":4033,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1597228.1427609362],[-10172851.218595313,1599674.127665624],[-10170405.233690625,1599674.127665624],[-10170405.233690625,1597228.1427609362],[-10172851.218595313,1597228.1427609362]]]]},"properties":{"x":4033,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1599674.127665624],[-10172851.218595313,1602120.1125703119],[-10170405.233690625,1602120.1125703119],[-10170405.233690625,1599674.127665624],[-10172851.218595313,1599674.127665624]]]]},"properties":{"x":4033,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1602120.1125703119],[-10172851.218595313,1604566.0974749997],[-10170405.233690625,1604566.0974749997],[-10170405.233690625,1602120.1125703119],[-10172851.218595313,1602120.1125703119]]]]},"properties":{"x":4033,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1604566.0974749997],[-10172851.218595313,1607012.0823796876],[-10170405.233690625,1607012.0823796876],[-10170405.233690625,1604566.0974749997],[-10172851.218595313,1604566.0974749997]]]]},"properties":{"x":4033,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1607012.0823796876],[-10172851.218595313,1609458.0672843754],[-10170405.233690625,1609458.0672843754],[-10170405.233690625,1607012.0823796876],[-10172851.218595313,1607012.0823796876]]]]},"properties":{"x":4033,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1609458.0672843754],[-10172851.218595313,1611904.0521890633],[-10170405.233690625,1611904.0521890633],[-10170405.233690625,1609458.0672843754],[-10172851.218595313,1609458.0672843754]]]]},"properties":{"x":4033,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1611904.0521890633],[-10172851.218595313,1614350.0370937511],[-10170405.233690625,1614350.0370937511],[-10170405.233690625,1611904.0521890633],[-10172851.218595313,1611904.0521890633]]]]},"properties":{"x":4033,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1614350.0370937511],[-10172851.218595313,1616796.021998439],[-10170405.233690625,1616796.021998439],[-10170405.233690625,1614350.0370937511],[-10172851.218595313,1614350.0370937511]]]]},"properties":{"x":4033,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1616796.021998439],[-10172851.218595313,1619242.0069031268],[-10170405.233690625,1619242.0069031268],[-10170405.233690625,1616796.021998439],[-10172851.218595313,1616796.021998439]]]]},"properties":{"x":4033,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1619242.0069031268],[-10172851.218595313,1621687.991807811],[-10170405.233690625,1621687.991807811],[-10170405.233690625,1619242.0069031268],[-10172851.218595313,1619242.0069031268]]]]},"properties":{"x":4033,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1621687.991807811],[-10172851.218595313,1624133.9767124988],[-10170405.233690625,1624133.9767124988],[-10170405.233690625,1621687.991807811],[-10172851.218595313,1621687.991807811]]]]},"properties":{"x":4033,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10172851.218595313,1624133.9767124988],[-10172851.218595313,1626579.9616171867],[-10170405.233690625,1626579.9616171867],[-10170405.233690625,1624133.9767124988],[-10172851.218595313,1624133.9767124988]]]]},"properties":{"x":4033,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1562984.3540953137],[-10170405.233690625,1565430.3390000015],[-10167959.248785937,1565430.3390000015],[-10167959.248785937,1562984.3540953137],[-10170405.233690625,1562984.3540953137]]]]},"properties":{"x":4034,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1565430.3390000015],[-10170405.233690625,1567876.3239046894],[-10167959.248785937,1567876.3239046894],[-10167959.248785937,1565430.3390000015],[-10170405.233690625,1565430.3390000015]]]]},"properties":{"x":4034,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1567876.3239046894],[-10170405.233690625,1570322.3088093735],[-10167959.248785937,1570322.3088093735],[-10167959.248785937,1567876.3239046894],[-10170405.233690625,1567876.3239046894]]]]},"properties":{"x":4034,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1570322.3088093735],[-10170405.233690625,1572768.2937140614],[-10167959.248785937,1572768.2937140614],[-10167959.248785937,1570322.3088093735],[-10170405.233690625,1570322.3088093735]]]]},"properties":{"x":4034,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1572768.2937140614],[-10170405.233690625,1575214.2786187492],[-10167959.248785937,1575214.2786187492],[-10167959.248785937,1572768.2937140614],[-10170405.233690625,1572768.2937140614]]]]},"properties":{"x":4034,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1575214.2786187492],[-10170405.233690625,1577660.263523437],[-10167959.248785937,1577660.263523437],[-10167959.248785937,1575214.2786187492],[-10170405.233690625,1575214.2786187492]]]]},"properties":{"x":4034,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1577660.263523437],[-10170405.233690625,1580106.248428125],[-10167959.248785937,1580106.248428125],[-10167959.248785937,1577660.263523437],[-10170405.233690625,1577660.263523437]]]]},"properties":{"x":4034,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1580106.248428125],[-10170405.233690625,1582552.2333328128],[-10167959.248785937,1582552.2333328128],[-10167959.248785937,1580106.248428125],[-10170405.233690625,1580106.248428125]]]]},"properties":{"x":4034,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1582552.2333328128],[-10170405.233690625,1584998.2182375006],[-10167959.248785937,1584998.2182375006],[-10167959.248785937,1582552.2333328128],[-10170405.233690625,1582552.2333328128]]]]},"properties":{"x":4034,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1584998.2182375006],[-10170405.233690625,1587444.2031421885],[-10167959.248785937,1587444.2031421885],[-10167959.248785937,1584998.2182375006],[-10170405.233690625,1584998.2182375006]]]]},"properties":{"x":4034,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1587444.2031421885],[-10170405.233690625,1589890.1880468763],[-10167959.248785937,1589890.1880468763],[-10167959.248785937,1587444.2031421885],[-10170405.233690625,1587444.2031421885]]]]},"properties":{"x":4034,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1589890.1880468763],[-10170405.233690625,1592336.1729515642],[-10167959.248785937,1592336.1729515642],[-10167959.248785937,1589890.1880468763],[-10170405.233690625,1589890.1880468763]]]]},"properties":{"x":4034,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1592336.1729515642],[-10170405.233690625,1594782.1578562483],[-10167959.248785937,1594782.1578562483],[-10167959.248785937,1592336.1729515642],[-10170405.233690625,1592336.1729515642]]]]},"properties":{"x":4034,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1594782.1578562483],[-10170405.233690625,1597228.1427609362],[-10167959.248785937,1597228.1427609362],[-10167959.248785937,1594782.1578562483],[-10170405.233690625,1594782.1578562483]]]]},"properties":{"x":4034,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1597228.1427609362],[-10170405.233690625,1599674.127665624],[-10167959.248785937,1599674.127665624],[-10167959.248785937,1597228.1427609362],[-10170405.233690625,1597228.1427609362]]]]},"properties":{"x":4034,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1599674.127665624],[-10170405.233690625,1602120.1125703119],[-10167959.248785937,1602120.1125703119],[-10167959.248785937,1599674.127665624],[-10170405.233690625,1599674.127665624]]]]},"properties":{"x":4034,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1602120.1125703119],[-10170405.233690625,1604566.0974749997],[-10167959.248785937,1604566.0974749997],[-10167959.248785937,1602120.1125703119],[-10170405.233690625,1602120.1125703119]]]]},"properties":{"x":4034,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1604566.0974749997],[-10170405.233690625,1607012.0823796876],[-10167959.248785937,1607012.0823796876],[-10167959.248785937,1604566.0974749997],[-10170405.233690625,1604566.0974749997]]]]},"properties":{"x":4034,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1607012.0823796876],[-10170405.233690625,1609458.0672843754],[-10167959.248785937,1609458.0672843754],[-10167959.248785937,1607012.0823796876],[-10170405.233690625,1607012.0823796876]]]]},"properties":{"x":4034,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1609458.0672843754],[-10170405.233690625,1611904.0521890633],[-10167959.248785937,1611904.0521890633],[-10167959.248785937,1609458.0672843754],[-10170405.233690625,1609458.0672843754]]]]},"properties":{"x":4034,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1611904.0521890633],[-10170405.233690625,1614350.0370937511],[-10167959.248785937,1614350.0370937511],[-10167959.248785937,1611904.0521890633],[-10170405.233690625,1611904.0521890633]]]]},"properties":{"x":4034,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1614350.0370937511],[-10170405.233690625,1616796.021998439],[-10167959.248785937,1616796.021998439],[-10167959.248785937,1614350.0370937511],[-10170405.233690625,1614350.0370937511]]]]},"properties":{"x":4034,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1616796.021998439],[-10170405.233690625,1619242.0069031268],[-10167959.248785937,1619242.0069031268],[-10167959.248785937,1616796.021998439],[-10170405.233690625,1616796.021998439]]]]},"properties":{"x":4034,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1619242.0069031268],[-10170405.233690625,1621687.991807811],[-10167959.248785937,1621687.991807811],[-10167959.248785937,1619242.0069031268],[-10170405.233690625,1619242.0069031268]]]]},"properties":{"x":4034,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1621687.991807811],[-10170405.233690625,1624133.9767124988],[-10167959.248785937,1624133.9767124988],[-10167959.248785937,1621687.991807811],[-10170405.233690625,1621687.991807811]]]]},"properties":{"x":4034,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10170405.233690625,1624133.9767124988],[-10170405.233690625,1626579.9616171867],[-10167959.248785937,1626579.9616171867],[-10167959.248785937,1624133.9767124988],[-10170405.233690625,1624133.9767124988]]]]},"properties":{"x":4034,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1562984.3540953137],[-10167959.248785937,1565430.3390000015],[-10165513.263881251,1565430.3390000015],[-10165513.263881251,1562984.3540953137],[-10167959.248785937,1562984.3540953137]]]]},"properties":{"x":4035,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1565430.3390000015],[-10167959.248785937,1567876.3239046894],[-10165513.263881251,1567876.3239046894],[-10165513.263881251,1565430.3390000015],[-10167959.248785937,1565430.3390000015]]]]},"properties":{"x":4035,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1567876.3239046894],[-10167959.248785937,1570322.3088093735],[-10165513.263881251,1570322.3088093735],[-10165513.263881251,1567876.3239046894],[-10167959.248785937,1567876.3239046894]]]]},"properties":{"x":4035,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1570322.3088093735],[-10167959.248785937,1572768.2937140614],[-10165513.263881251,1572768.2937140614],[-10165513.263881251,1570322.3088093735],[-10167959.248785937,1570322.3088093735]]]]},"properties":{"x":4035,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1572768.2937140614],[-10167959.248785937,1575214.2786187492],[-10165513.263881251,1575214.2786187492],[-10165513.263881251,1572768.2937140614],[-10167959.248785937,1572768.2937140614]]]]},"properties":{"x":4035,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1575214.2786187492],[-10167959.248785937,1577660.263523437],[-10165513.263881251,1577660.263523437],[-10165513.263881251,1575214.2786187492],[-10167959.248785937,1575214.2786187492]]]]},"properties":{"x":4035,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1577660.263523437],[-10167959.248785937,1580106.248428125],[-10165513.263881251,1580106.248428125],[-10165513.263881251,1577660.263523437],[-10167959.248785937,1577660.263523437]]]]},"properties":{"x":4035,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1580106.248428125],[-10167959.248785937,1582552.2333328128],[-10165513.263881251,1582552.2333328128],[-10165513.263881251,1580106.248428125],[-10167959.248785937,1580106.248428125]]]]},"properties":{"x":4035,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1582552.2333328128],[-10167959.248785937,1584998.2182375006],[-10165513.263881251,1584998.2182375006],[-10165513.263881251,1582552.2333328128],[-10167959.248785937,1582552.2333328128]]]]},"properties":{"x":4035,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1584998.2182375006],[-10167959.248785937,1587444.2031421885],[-10165513.263881251,1587444.2031421885],[-10165513.263881251,1584998.2182375006],[-10167959.248785937,1584998.2182375006]]]]},"properties":{"x":4035,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1587444.2031421885],[-10167959.248785937,1589890.1880468763],[-10165513.263881251,1589890.1880468763],[-10165513.263881251,1587444.2031421885],[-10167959.248785937,1587444.2031421885]]]]},"properties":{"x":4035,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1589890.1880468763],[-10167959.248785937,1592336.1729515642],[-10165513.263881251,1592336.1729515642],[-10165513.263881251,1589890.1880468763],[-10167959.248785937,1589890.1880468763]]]]},"properties":{"x":4035,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1592336.1729515642],[-10167959.248785937,1594782.1578562483],[-10165513.263881251,1594782.1578562483],[-10165513.263881251,1592336.1729515642],[-10167959.248785937,1592336.1729515642]]]]},"properties":{"x":4035,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1594782.1578562483],[-10167959.248785937,1597228.1427609362],[-10165513.263881251,1597228.1427609362],[-10165513.263881251,1594782.1578562483],[-10167959.248785937,1594782.1578562483]]]]},"properties":{"x":4035,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1597228.1427609362],[-10167959.248785937,1599674.127665624],[-10165513.263881251,1599674.127665624],[-10165513.263881251,1597228.1427609362],[-10167959.248785937,1597228.1427609362]]]]},"properties":{"x":4035,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1599674.127665624],[-10167959.248785937,1602120.1125703119],[-10165513.263881251,1602120.1125703119],[-10165513.263881251,1599674.127665624],[-10167959.248785937,1599674.127665624]]]]},"properties":{"x":4035,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1602120.1125703119],[-10167959.248785937,1604566.0974749997],[-10165513.263881251,1604566.0974749997],[-10165513.263881251,1602120.1125703119],[-10167959.248785937,1602120.1125703119]]]]},"properties":{"x":4035,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1604566.0974749997],[-10167959.248785937,1607012.0823796876],[-10165513.263881251,1607012.0823796876],[-10165513.263881251,1604566.0974749997],[-10167959.248785937,1604566.0974749997]]]]},"properties":{"x":4035,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1607012.0823796876],[-10167959.248785937,1609458.0672843754],[-10165513.263881251,1609458.0672843754],[-10165513.263881251,1607012.0823796876],[-10167959.248785937,1607012.0823796876]]]]},"properties":{"x":4035,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1609458.0672843754],[-10167959.248785937,1611904.0521890633],[-10165513.263881251,1611904.0521890633],[-10165513.263881251,1609458.0672843754],[-10167959.248785937,1609458.0672843754]]]]},"properties":{"x":4035,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1611904.0521890633],[-10167959.248785937,1614350.0370937511],[-10165513.263881251,1614350.0370937511],[-10165513.263881251,1611904.0521890633],[-10167959.248785937,1611904.0521890633]]]]},"properties":{"x":4035,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1614350.0370937511],[-10167959.248785937,1616796.021998439],[-10165513.263881251,1616796.021998439],[-10165513.263881251,1614350.0370937511],[-10167959.248785937,1614350.0370937511]]]]},"properties":{"x":4035,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1616796.021998439],[-10167959.248785937,1619242.0069031268],[-10165513.263881251,1619242.0069031268],[-10165513.263881251,1616796.021998439],[-10167959.248785937,1616796.021998439]]]]},"properties":{"x":4035,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1619242.0069031268],[-10167959.248785937,1621687.991807811],[-10165513.263881251,1621687.991807811],[-10165513.263881251,1619242.0069031268],[-10167959.248785937,1619242.0069031268]]]]},"properties":{"x":4035,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1621687.991807811],[-10167959.248785937,1624133.9767124988],[-10165513.263881251,1624133.9767124988],[-10165513.263881251,1621687.991807811],[-10167959.248785937,1621687.991807811]]]]},"properties":{"x":4035,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10167959.248785937,1624133.9767124988],[-10167959.248785937,1626579.9616171867],[-10165513.263881251,1626579.9616171867],[-10165513.263881251,1624133.9767124988],[-10167959.248785937,1624133.9767124988]]]]},"properties":{"x":4035,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1562984.3540953137],[-10165513.263881251,1565430.3390000015],[-10163067.278976563,1565430.3390000015],[-10163067.278976563,1562984.3540953137],[-10165513.263881251,1562984.3540953137]]]]},"properties":{"x":4036,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1565430.3390000015],[-10165513.263881251,1567876.3239046894],[-10163067.278976563,1567876.3239046894],[-10163067.278976563,1565430.3390000015],[-10165513.263881251,1565430.3390000015]]]]},"properties":{"x":4036,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1567876.3239046894],[-10165513.263881251,1570322.3088093735],[-10163067.278976563,1570322.3088093735],[-10163067.278976563,1567876.3239046894],[-10165513.263881251,1567876.3239046894]]]]},"properties":{"x":4036,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1570322.3088093735],[-10165513.263881251,1572768.2937140614],[-10163067.278976563,1572768.2937140614],[-10163067.278976563,1570322.3088093735],[-10165513.263881251,1570322.3088093735]]]]},"properties":{"x":4036,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1572768.2937140614],[-10165513.263881251,1575214.2786187492],[-10163067.278976563,1575214.2786187492],[-10163067.278976563,1572768.2937140614],[-10165513.263881251,1572768.2937140614]]]]},"properties":{"x":4036,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1575214.2786187492],[-10165513.263881251,1577660.263523437],[-10163067.278976563,1577660.263523437],[-10163067.278976563,1575214.2786187492],[-10165513.263881251,1575214.2786187492]]]]},"properties":{"x":4036,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1577660.263523437],[-10165513.263881251,1580106.248428125],[-10163067.278976563,1580106.248428125],[-10163067.278976563,1577660.263523437],[-10165513.263881251,1577660.263523437]]]]},"properties":{"x":4036,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1580106.248428125],[-10165513.263881251,1582552.2333328128],[-10163067.278976563,1582552.2333328128],[-10163067.278976563,1580106.248428125],[-10165513.263881251,1580106.248428125]]]]},"properties":{"x":4036,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1582552.2333328128],[-10165513.263881251,1584998.2182375006],[-10163067.278976563,1584998.2182375006],[-10163067.278976563,1582552.2333328128],[-10165513.263881251,1582552.2333328128]]]]},"properties":{"x":4036,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1584998.2182375006],[-10165513.263881251,1587444.2031421885],[-10163067.278976563,1587444.2031421885],[-10163067.278976563,1584998.2182375006],[-10165513.263881251,1584998.2182375006]]]]},"properties":{"x":4036,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1587444.2031421885],[-10165513.263881251,1589890.1880468763],[-10163067.278976563,1589890.1880468763],[-10163067.278976563,1587444.2031421885],[-10165513.263881251,1587444.2031421885]]]]},"properties":{"x":4036,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1589890.1880468763],[-10165513.263881251,1592336.1729515642],[-10163067.278976563,1592336.1729515642],[-10163067.278976563,1589890.1880468763],[-10165513.263881251,1589890.1880468763]]]]},"properties":{"x":4036,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1592336.1729515642],[-10165513.263881251,1594782.1578562483],[-10163067.278976563,1594782.1578562483],[-10163067.278976563,1592336.1729515642],[-10165513.263881251,1592336.1729515642]]]]},"properties":{"x":4036,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1594782.1578562483],[-10165513.263881251,1597228.1427609362],[-10163067.278976563,1597228.1427609362],[-10163067.278976563,1594782.1578562483],[-10165513.263881251,1594782.1578562483]]]]},"properties":{"x":4036,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1597228.1427609362],[-10165513.263881251,1599674.127665624],[-10163067.278976563,1599674.127665624],[-10163067.278976563,1597228.1427609362],[-10165513.263881251,1597228.1427609362]]]]},"properties":{"x":4036,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1599674.127665624],[-10165513.263881251,1602120.1125703119],[-10163067.278976563,1602120.1125703119],[-10163067.278976563,1599674.127665624],[-10165513.263881251,1599674.127665624]]]]},"properties":{"x":4036,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1602120.1125703119],[-10165513.263881251,1604566.0974749997],[-10163067.278976563,1604566.0974749997],[-10163067.278976563,1602120.1125703119],[-10165513.263881251,1602120.1125703119]]]]},"properties":{"x":4036,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1604566.0974749997],[-10165513.263881251,1607012.0823796876],[-10163067.278976563,1607012.0823796876],[-10163067.278976563,1604566.0974749997],[-10165513.263881251,1604566.0974749997]]]]},"properties":{"x":4036,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1607012.0823796876],[-10165513.263881251,1609458.0672843754],[-10163067.278976563,1609458.0672843754],[-10163067.278976563,1607012.0823796876],[-10165513.263881251,1607012.0823796876]]]]},"properties":{"x":4036,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1609458.0672843754],[-10165513.263881251,1611904.0521890633],[-10163067.278976563,1611904.0521890633],[-10163067.278976563,1609458.0672843754],[-10165513.263881251,1609458.0672843754]]]]},"properties":{"x":4036,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1611904.0521890633],[-10165513.263881251,1614350.0370937511],[-10163067.278976563,1614350.0370937511],[-10163067.278976563,1611904.0521890633],[-10165513.263881251,1611904.0521890633]]]]},"properties":{"x":4036,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1614350.0370937511],[-10165513.263881251,1616796.021998439],[-10163067.278976563,1616796.021998439],[-10163067.278976563,1614350.0370937511],[-10165513.263881251,1614350.0370937511]]]]},"properties":{"x":4036,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1616796.021998439],[-10165513.263881251,1619242.0069031268],[-10163067.278976563,1619242.0069031268],[-10163067.278976563,1616796.021998439],[-10165513.263881251,1616796.021998439]]]]},"properties":{"x":4036,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1619242.0069031268],[-10165513.263881251,1621687.991807811],[-10163067.278976563,1621687.991807811],[-10163067.278976563,1619242.0069031268],[-10165513.263881251,1619242.0069031268]]]]},"properties":{"x":4036,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1621687.991807811],[-10165513.263881251,1624133.9767124988],[-10163067.278976563,1624133.9767124988],[-10163067.278976563,1621687.991807811],[-10165513.263881251,1621687.991807811]]]]},"properties":{"x":4036,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10165513.263881251,1624133.9767124988],[-10165513.263881251,1626579.9616171867],[-10163067.278976563,1626579.9616171867],[-10163067.278976563,1624133.9767124988],[-10165513.263881251,1624133.9767124988]]]]},"properties":{"x":4036,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1562984.3540953137],[-10163067.278976563,1565430.3390000015],[-10160621.294071876,1565430.3390000015],[-10160621.294071876,1562984.3540953137],[-10163067.278976563,1562984.3540953137]]]]},"properties":{"x":4037,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1565430.3390000015],[-10163067.278976563,1567876.3239046894],[-10160621.294071876,1567876.3239046894],[-10160621.294071876,1565430.3390000015],[-10163067.278976563,1565430.3390000015]]]]},"properties":{"x":4037,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1567876.3239046894],[-10163067.278976563,1570322.3088093735],[-10160621.294071876,1570322.3088093735],[-10160621.294071876,1567876.3239046894],[-10163067.278976563,1567876.3239046894]]]]},"properties":{"x":4037,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1570322.3088093735],[-10163067.278976563,1572768.2937140614],[-10160621.294071876,1572768.2937140614],[-10160621.294071876,1570322.3088093735],[-10163067.278976563,1570322.3088093735]]]]},"properties":{"x":4037,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1572768.2937140614],[-10163067.278976563,1575214.2786187492],[-10160621.294071876,1575214.2786187492],[-10160621.294071876,1572768.2937140614],[-10163067.278976563,1572768.2937140614]]]]},"properties":{"x":4037,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1575214.2786187492],[-10163067.278976563,1577660.263523437],[-10160621.294071876,1577660.263523437],[-10160621.294071876,1575214.2786187492],[-10163067.278976563,1575214.2786187492]]]]},"properties":{"x":4037,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1577660.263523437],[-10163067.278976563,1580106.248428125],[-10160621.294071876,1580106.248428125],[-10160621.294071876,1577660.263523437],[-10163067.278976563,1577660.263523437]]]]},"properties":{"x":4037,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1580106.248428125],[-10163067.278976563,1582552.2333328128],[-10160621.294071876,1582552.2333328128],[-10160621.294071876,1580106.248428125],[-10163067.278976563,1580106.248428125]]]]},"properties":{"x":4037,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1582552.2333328128],[-10163067.278976563,1584998.2182375006],[-10160621.294071876,1584998.2182375006],[-10160621.294071876,1582552.2333328128],[-10163067.278976563,1582552.2333328128]]]]},"properties":{"x":4037,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1584998.2182375006],[-10163067.278976563,1587444.2031421885],[-10160621.294071876,1587444.2031421885],[-10160621.294071876,1584998.2182375006],[-10163067.278976563,1584998.2182375006]]]]},"properties":{"x":4037,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1587444.2031421885],[-10163067.278976563,1589890.1880468763],[-10160621.294071876,1589890.1880468763],[-10160621.294071876,1587444.2031421885],[-10163067.278976563,1587444.2031421885]]]]},"properties":{"x":4037,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1589890.1880468763],[-10163067.278976563,1592336.1729515642],[-10160621.294071876,1592336.1729515642],[-10160621.294071876,1589890.1880468763],[-10163067.278976563,1589890.1880468763]]]]},"properties":{"x":4037,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1592336.1729515642],[-10163067.278976563,1594782.1578562483],[-10160621.294071876,1594782.1578562483],[-10160621.294071876,1592336.1729515642],[-10163067.278976563,1592336.1729515642]]]]},"properties":{"x":4037,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1594782.1578562483],[-10163067.278976563,1597228.1427609362],[-10160621.294071876,1597228.1427609362],[-10160621.294071876,1594782.1578562483],[-10163067.278976563,1594782.1578562483]]]]},"properties":{"x":4037,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1597228.1427609362],[-10163067.278976563,1599674.127665624],[-10160621.294071876,1599674.127665624],[-10160621.294071876,1597228.1427609362],[-10163067.278976563,1597228.1427609362]]]]},"properties":{"x":4037,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1599674.127665624],[-10163067.278976563,1602120.1125703119],[-10160621.294071876,1602120.1125703119],[-10160621.294071876,1599674.127665624],[-10163067.278976563,1599674.127665624]]]]},"properties":{"x":4037,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1602120.1125703119],[-10163067.278976563,1604566.0974749997],[-10160621.294071876,1604566.0974749997],[-10160621.294071876,1602120.1125703119],[-10163067.278976563,1602120.1125703119]]]]},"properties":{"x":4037,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1604566.0974749997],[-10163067.278976563,1607012.0823796876],[-10160621.294071876,1607012.0823796876],[-10160621.294071876,1604566.0974749997],[-10163067.278976563,1604566.0974749997]]]]},"properties":{"x":4037,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1607012.0823796876],[-10163067.278976563,1609458.0672843754],[-10160621.294071876,1609458.0672843754],[-10160621.294071876,1607012.0823796876],[-10163067.278976563,1607012.0823796876]]]]},"properties":{"x":4037,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1609458.0672843754],[-10163067.278976563,1611904.0521890633],[-10160621.294071876,1611904.0521890633],[-10160621.294071876,1609458.0672843754],[-10163067.278976563,1609458.0672843754]]]]},"properties":{"x":4037,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1611904.0521890633],[-10163067.278976563,1614350.0370937511],[-10160621.294071876,1614350.0370937511],[-10160621.294071876,1611904.0521890633],[-10163067.278976563,1611904.0521890633]]]]},"properties":{"x":4037,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1614350.0370937511],[-10163067.278976563,1616796.021998439],[-10160621.294071876,1616796.021998439],[-10160621.294071876,1614350.0370937511],[-10163067.278976563,1614350.0370937511]]]]},"properties":{"x":4037,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1616796.021998439],[-10163067.278976563,1619242.0069031268],[-10160621.294071876,1619242.0069031268],[-10160621.294071876,1616796.021998439],[-10163067.278976563,1616796.021998439]]]]},"properties":{"x":4037,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1619242.0069031268],[-10163067.278976563,1621687.991807811],[-10160621.294071876,1621687.991807811],[-10160621.294071876,1619242.0069031268],[-10163067.278976563,1619242.0069031268]]]]},"properties":{"x":4037,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1621687.991807811],[-10163067.278976563,1624133.9767124988],[-10160621.294071876,1624133.9767124988],[-10160621.294071876,1621687.991807811],[-10163067.278976563,1621687.991807811]]]]},"properties":{"x":4037,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10163067.278976563,1624133.9767124988],[-10163067.278976563,1626579.9616171867],[-10160621.294071876,1626579.9616171867],[-10160621.294071876,1624133.9767124988],[-10163067.278976563,1624133.9767124988]]]]},"properties":{"x":4037,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1562984.3540953137],[-10160621.294071876,1565430.3390000015],[-10158175.309167188,1565430.3390000015],[-10158175.309167188,1562984.3540953137],[-10160621.294071876,1562984.3540953137]]]]},"properties":{"x":4038,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1565430.3390000015],[-10160621.294071876,1567876.3239046894],[-10158175.309167188,1567876.3239046894],[-10158175.309167188,1565430.3390000015],[-10160621.294071876,1565430.3390000015]]]]},"properties":{"x":4038,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1567876.3239046894],[-10160621.294071876,1570322.3088093735],[-10158175.309167188,1570322.3088093735],[-10158175.309167188,1567876.3239046894],[-10160621.294071876,1567876.3239046894]]]]},"properties":{"x":4038,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1570322.3088093735],[-10160621.294071876,1572768.2937140614],[-10158175.309167188,1572768.2937140614],[-10158175.309167188,1570322.3088093735],[-10160621.294071876,1570322.3088093735]]]]},"properties":{"x":4038,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1572768.2937140614],[-10160621.294071876,1575214.2786187492],[-10158175.309167188,1575214.2786187492],[-10158175.309167188,1572768.2937140614],[-10160621.294071876,1572768.2937140614]]]]},"properties":{"x":4038,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1575214.2786187492],[-10160621.294071876,1577660.263523437],[-10158175.309167188,1577660.263523437],[-10158175.309167188,1575214.2786187492],[-10160621.294071876,1575214.2786187492]]]]},"properties":{"x":4038,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1577660.263523437],[-10160621.294071876,1580106.248428125],[-10158175.309167188,1580106.248428125],[-10158175.309167188,1577660.263523437],[-10160621.294071876,1577660.263523437]]]]},"properties":{"x":4038,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1580106.248428125],[-10160621.294071876,1582552.2333328128],[-10158175.309167188,1582552.2333328128],[-10158175.309167188,1580106.248428125],[-10160621.294071876,1580106.248428125]]]]},"properties":{"x":4038,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1582552.2333328128],[-10160621.294071876,1584998.2182375006],[-10158175.309167188,1584998.2182375006],[-10158175.309167188,1582552.2333328128],[-10160621.294071876,1582552.2333328128]]]]},"properties":{"x":4038,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1584998.2182375006],[-10160621.294071876,1587444.2031421885],[-10158175.309167188,1587444.2031421885],[-10158175.309167188,1584998.2182375006],[-10160621.294071876,1584998.2182375006]]]]},"properties":{"x":4038,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1587444.2031421885],[-10160621.294071876,1589890.1880468763],[-10158175.309167188,1589890.1880468763],[-10158175.309167188,1587444.2031421885],[-10160621.294071876,1587444.2031421885]]]]},"properties":{"x":4038,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1589890.1880468763],[-10160621.294071876,1592336.1729515642],[-10158175.309167188,1592336.1729515642],[-10158175.309167188,1589890.1880468763],[-10160621.294071876,1589890.1880468763]]]]},"properties":{"x":4038,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1592336.1729515642],[-10160621.294071876,1594782.1578562483],[-10158175.309167188,1594782.1578562483],[-10158175.309167188,1592336.1729515642],[-10160621.294071876,1592336.1729515642]]]]},"properties":{"x":4038,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1594782.1578562483],[-10160621.294071876,1597228.1427609362],[-10158175.309167188,1597228.1427609362],[-10158175.309167188,1594782.1578562483],[-10160621.294071876,1594782.1578562483]]]]},"properties":{"x":4038,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1597228.1427609362],[-10160621.294071876,1599674.127665624],[-10158175.309167188,1599674.127665624],[-10158175.309167188,1597228.1427609362],[-10160621.294071876,1597228.1427609362]]]]},"properties":{"x":4038,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1599674.127665624],[-10160621.294071876,1602120.1125703119],[-10158175.309167188,1602120.1125703119],[-10158175.309167188,1599674.127665624],[-10160621.294071876,1599674.127665624]]]]},"properties":{"x":4038,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1602120.1125703119],[-10160621.294071876,1604566.0974749997],[-10158175.309167188,1604566.0974749997],[-10158175.309167188,1602120.1125703119],[-10160621.294071876,1602120.1125703119]]]]},"properties":{"x":4038,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1604566.0974749997],[-10160621.294071876,1607012.0823796876],[-10158175.309167188,1607012.0823796876],[-10158175.309167188,1604566.0974749997],[-10160621.294071876,1604566.0974749997]]]]},"properties":{"x":4038,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1607012.0823796876],[-10160621.294071876,1609458.0672843754],[-10158175.309167188,1609458.0672843754],[-10158175.309167188,1607012.0823796876],[-10160621.294071876,1607012.0823796876]]]]},"properties":{"x":4038,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1609458.0672843754],[-10160621.294071876,1611904.0521890633],[-10158175.309167188,1611904.0521890633],[-10158175.309167188,1609458.0672843754],[-10160621.294071876,1609458.0672843754]]]]},"properties":{"x":4038,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1611904.0521890633],[-10160621.294071876,1614350.0370937511],[-10158175.309167188,1614350.0370937511],[-10158175.309167188,1611904.0521890633],[-10160621.294071876,1611904.0521890633]]]]},"properties":{"x":4038,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1614350.0370937511],[-10160621.294071876,1616796.021998439],[-10158175.309167188,1616796.021998439],[-10158175.309167188,1614350.0370937511],[-10160621.294071876,1614350.0370937511]]]]},"properties":{"x":4038,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1616796.021998439],[-10160621.294071876,1619242.0069031268],[-10158175.309167188,1619242.0069031268],[-10158175.309167188,1616796.021998439],[-10160621.294071876,1616796.021998439]]]]},"properties":{"x":4038,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1619242.0069031268],[-10160621.294071876,1621687.991807811],[-10158175.309167188,1621687.991807811],[-10158175.309167188,1619242.0069031268],[-10160621.294071876,1619242.0069031268]]]]},"properties":{"x":4038,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1621687.991807811],[-10160621.294071876,1624133.9767124988],[-10158175.309167188,1624133.9767124988],[-10158175.309167188,1621687.991807811],[-10160621.294071876,1621687.991807811]]]]},"properties":{"x":4038,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10160621.294071876,1624133.9767124988],[-10160621.294071876,1626579.9616171867],[-10158175.309167188,1626579.9616171867],[-10158175.309167188,1624133.9767124988],[-10160621.294071876,1624133.9767124988]]]]},"properties":{"x":4038,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1562984.3540953137],[-10158175.309167188,1565430.3390000015],[-10155729.3242625,1565430.3390000015],[-10155729.3242625,1562984.3540953137],[-10158175.309167188,1562984.3540953137]]]]},"properties":{"x":4039,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1565430.3390000015],[-10158175.309167188,1567876.3239046894],[-10155729.3242625,1567876.3239046894],[-10155729.3242625,1565430.3390000015],[-10158175.309167188,1565430.3390000015]]]]},"properties":{"x":4039,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1567876.3239046894],[-10158175.309167188,1570322.3088093735],[-10155729.3242625,1570322.3088093735],[-10155729.3242625,1567876.3239046894],[-10158175.309167188,1567876.3239046894]]]]},"properties":{"x":4039,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1570322.3088093735],[-10158175.309167188,1572768.2937140614],[-10155729.3242625,1572768.2937140614],[-10155729.3242625,1570322.3088093735],[-10158175.309167188,1570322.3088093735]]]]},"properties":{"x":4039,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1572768.2937140614],[-10158175.309167188,1575214.2786187492],[-10155729.3242625,1575214.2786187492],[-10155729.3242625,1572768.2937140614],[-10158175.309167188,1572768.2937140614]]]]},"properties":{"x":4039,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1575214.2786187492],[-10158175.309167188,1577660.263523437],[-10155729.3242625,1577660.263523437],[-10155729.3242625,1575214.2786187492],[-10158175.309167188,1575214.2786187492]]]]},"properties":{"x":4039,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1577660.263523437],[-10158175.309167188,1580106.248428125],[-10155729.3242625,1580106.248428125],[-10155729.3242625,1577660.263523437],[-10158175.309167188,1577660.263523437]]]]},"properties":{"x":4039,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1580106.248428125],[-10158175.309167188,1582552.2333328128],[-10155729.3242625,1582552.2333328128],[-10155729.3242625,1580106.248428125],[-10158175.309167188,1580106.248428125]]]]},"properties":{"x":4039,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1582552.2333328128],[-10158175.309167188,1584998.2182375006],[-10155729.3242625,1584998.2182375006],[-10155729.3242625,1582552.2333328128],[-10158175.309167188,1582552.2333328128]]]]},"properties":{"x":4039,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1584998.2182375006],[-10158175.309167188,1587444.2031421885],[-10155729.3242625,1587444.2031421885],[-10155729.3242625,1584998.2182375006],[-10158175.309167188,1584998.2182375006]]]]},"properties":{"x":4039,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1587444.2031421885],[-10158175.309167188,1589890.1880468763],[-10155729.3242625,1589890.1880468763],[-10155729.3242625,1587444.2031421885],[-10158175.309167188,1587444.2031421885]]]]},"properties":{"x":4039,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1589890.1880468763],[-10158175.309167188,1592336.1729515642],[-10155729.3242625,1592336.1729515642],[-10155729.3242625,1589890.1880468763],[-10158175.309167188,1589890.1880468763]]]]},"properties":{"x":4039,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1592336.1729515642],[-10158175.309167188,1594782.1578562483],[-10155729.3242625,1594782.1578562483],[-10155729.3242625,1592336.1729515642],[-10158175.309167188,1592336.1729515642]]]]},"properties":{"x":4039,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1594782.1578562483],[-10158175.309167188,1597228.1427609362],[-10155729.3242625,1597228.1427609362],[-10155729.3242625,1594782.1578562483],[-10158175.309167188,1594782.1578562483]]]]},"properties":{"x":4039,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1597228.1427609362],[-10158175.309167188,1599674.127665624],[-10155729.3242625,1599674.127665624],[-10155729.3242625,1597228.1427609362],[-10158175.309167188,1597228.1427609362]]]]},"properties":{"x":4039,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1599674.127665624],[-10158175.309167188,1602120.1125703119],[-10155729.3242625,1602120.1125703119],[-10155729.3242625,1599674.127665624],[-10158175.309167188,1599674.127665624]]]]},"properties":{"x":4039,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1602120.1125703119],[-10158175.309167188,1604566.0974749997],[-10155729.3242625,1604566.0974749997],[-10155729.3242625,1602120.1125703119],[-10158175.309167188,1602120.1125703119]]]]},"properties":{"x":4039,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1604566.0974749997],[-10158175.309167188,1607012.0823796876],[-10155729.3242625,1607012.0823796876],[-10155729.3242625,1604566.0974749997],[-10158175.309167188,1604566.0974749997]]]]},"properties":{"x":4039,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1607012.0823796876],[-10158175.309167188,1609458.0672843754],[-10155729.3242625,1609458.0672843754],[-10155729.3242625,1607012.0823796876],[-10158175.309167188,1607012.0823796876]]]]},"properties":{"x":4039,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1609458.0672843754],[-10158175.309167188,1611904.0521890633],[-10155729.3242625,1611904.0521890633],[-10155729.3242625,1609458.0672843754],[-10158175.309167188,1609458.0672843754]]]]},"properties":{"x":4039,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1611904.0521890633],[-10158175.309167188,1614350.0370937511],[-10155729.3242625,1614350.0370937511],[-10155729.3242625,1611904.0521890633],[-10158175.309167188,1611904.0521890633]]]]},"properties":{"x":4039,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1614350.0370937511],[-10158175.309167188,1616796.021998439],[-10155729.3242625,1616796.021998439],[-10155729.3242625,1614350.0370937511],[-10158175.309167188,1614350.0370937511]]]]},"properties":{"x":4039,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1616796.021998439],[-10158175.309167188,1619242.0069031268],[-10155729.3242625,1619242.0069031268],[-10155729.3242625,1616796.021998439],[-10158175.309167188,1616796.021998439]]]]},"properties":{"x":4039,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1619242.0069031268],[-10158175.309167188,1621687.991807811],[-10155729.3242625,1621687.991807811],[-10155729.3242625,1619242.0069031268],[-10158175.309167188,1619242.0069031268]]]]},"properties":{"x":4039,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1621687.991807811],[-10158175.309167188,1624133.9767124988],[-10155729.3242625,1624133.9767124988],[-10155729.3242625,1621687.991807811],[-10158175.309167188,1621687.991807811]]]]},"properties":{"x":4039,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10158175.309167188,1624133.9767124988],[-10158175.309167188,1626579.9616171867],[-10155729.3242625,1626579.9616171867],[-10155729.3242625,1624133.9767124988],[-10158175.309167188,1624133.9767124988]]]]},"properties":{"x":4039,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1562984.3540953137],[-10155729.3242625,1565430.3390000015],[-10153283.339357814,1565430.3390000015],[-10153283.339357814,1562984.3540953137],[-10155729.3242625,1562984.3540953137]]]]},"properties":{"x":4040,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1565430.3390000015],[-10155729.3242625,1567876.3239046894],[-10153283.339357814,1567876.3239046894],[-10153283.339357814,1565430.3390000015],[-10155729.3242625,1565430.3390000015]]]]},"properties":{"x":4040,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1567876.3239046894],[-10155729.3242625,1570322.3088093735],[-10153283.339357814,1570322.3088093735],[-10153283.339357814,1567876.3239046894],[-10155729.3242625,1567876.3239046894]]]]},"properties":{"x":4040,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1570322.3088093735],[-10155729.3242625,1572768.2937140614],[-10153283.339357814,1572768.2937140614],[-10153283.339357814,1570322.3088093735],[-10155729.3242625,1570322.3088093735]]]]},"properties":{"x":4040,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1572768.2937140614],[-10155729.3242625,1575214.2786187492],[-10153283.339357814,1575214.2786187492],[-10153283.339357814,1572768.2937140614],[-10155729.3242625,1572768.2937140614]]]]},"properties":{"x":4040,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1575214.2786187492],[-10155729.3242625,1577660.263523437],[-10153283.339357814,1577660.263523437],[-10153283.339357814,1575214.2786187492],[-10155729.3242625,1575214.2786187492]]]]},"properties":{"x":4040,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1577660.263523437],[-10155729.3242625,1580106.248428125],[-10153283.339357814,1580106.248428125],[-10153283.339357814,1577660.263523437],[-10155729.3242625,1577660.263523437]]]]},"properties":{"x":4040,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1580106.248428125],[-10155729.3242625,1582552.2333328128],[-10153283.339357814,1582552.2333328128],[-10153283.339357814,1580106.248428125],[-10155729.3242625,1580106.248428125]]]]},"properties":{"x":4040,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1582552.2333328128],[-10155729.3242625,1584998.2182375006],[-10153283.339357814,1584998.2182375006],[-10153283.339357814,1582552.2333328128],[-10155729.3242625,1582552.2333328128]]]]},"properties":{"x":4040,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1584998.2182375006],[-10155729.3242625,1587444.2031421885],[-10153283.339357814,1587444.2031421885],[-10153283.339357814,1584998.2182375006],[-10155729.3242625,1584998.2182375006]]]]},"properties":{"x":4040,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1587444.2031421885],[-10155729.3242625,1589890.1880468763],[-10153283.339357814,1589890.1880468763],[-10153283.339357814,1587444.2031421885],[-10155729.3242625,1587444.2031421885]]]]},"properties":{"x":4040,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1589890.1880468763],[-10155729.3242625,1592336.1729515642],[-10153283.339357814,1592336.1729515642],[-10153283.339357814,1589890.1880468763],[-10155729.3242625,1589890.1880468763]]]]},"properties":{"x":4040,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1592336.1729515642],[-10155729.3242625,1594782.1578562483],[-10153283.339357814,1594782.1578562483],[-10153283.339357814,1592336.1729515642],[-10155729.3242625,1592336.1729515642]]]]},"properties":{"x":4040,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1594782.1578562483],[-10155729.3242625,1597228.1427609362],[-10153283.339357814,1597228.1427609362],[-10153283.339357814,1594782.1578562483],[-10155729.3242625,1594782.1578562483]]]]},"properties":{"x":4040,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1597228.1427609362],[-10155729.3242625,1599674.127665624],[-10153283.339357814,1599674.127665624],[-10153283.339357814,1597228.1427609362],[-10155729.3242625,1597228.1427609362]]]]},"properties":{"x":4040,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1599674.127665624],[-10155729.3242625,1602120.1125703119],[-10153283.339357814,1602120.1125703119],[-10153283.339357814,1599674.127665624],[-10155729.3242625,1599674.127665624]]]]},"properties":{"x":4040,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1602120.1125703119],[-10155729.3242625,1604566.0974749997],[-10153283.339357814,1604566.0974749997],[-10153283.339357814,1602120.1125703119],[-10155729.3242625,1602120.1125703119]]]]},"properties":{"x":4040,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1604566.0974749997],[-10155729.3242625,1607012.0823796876],[-10153283.339357814,1607012.0823796876],[-10153283.339357814,1604566.0974749997],[-10155729.3242625,1604566.0974749997]]]]},"properties":{"x":4040,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1607012.0823796876],[-10155729.3242625,1609458.0672843754],[-10153283.339357814,1609458.0672843754],[-10153283.339357814,1607012.0823796876],[-10155729.3242625,1607012.0823796876]]]]},"properties":{"x":4040,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1609458.0672843754],[-10155729.3242625,1611904.0521890633],[-10153283.339357814,1611904.0521890633],[-10153283.339357814,1609458.0672843754],[-10155729.3242625,1609458.0672843754]]]]},"properties":{"x":4040,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1611904.0521890633],[-10155729.3242625,1614350.0370937511],[-10153283.339357814,1614350.0370937511],[-10153283.339357814,1611904.0521890633],[-10155729.3242625,1611904.0521890633]]]]},"properties":{"x":4040,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1614350.0370937511],[-10155729.3242625,1616796.021998439],[-10153283.339357814,1616796.021998439],[-10153283.339357814,1614350.0370937511],[-10155729.3242625,1614350.0370937511]]]]},"properties":{"x":4040,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1616796.021998439],[-10155729.3242625,1619242.0069031268],[-10153283.339357814,1619242.0069031268],[-10153283.339357814,1616796.021998439],[-10155729.3242625,1616796.021998439]]]]},"properties":{"x":4040,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1619242.0069031268],[-10155729.3242625,1621687.991807811],[-10153283.339357814,1621687.991807811],[-10153283.339357814,1619242.0069031268],[-10155729.3242625,1619242.0069031268]]]]},"properties":{"x":4040,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1621687.991807811],[-10155729.3242625,1624133.9767124988],[-10153283.339357814,1624133.9767124988],[-10153283.339357814,1621687.991807811],[-10155729.3242625,1621687.991807811]]]]},"properties":{"x":4040,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10155729.3242625,1624133.9767124988],[-10155729.3242625,1626579.9616171867],[-10153283.339357814,1626579.9616171867],[-10153283.339357814,1624133.9767124988],[-10155729.3242625,1624133.9767124988]]]]},"properties":{"x":4040,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1562984.3540953137],[-10153283.339357814,1565430.3390000015],[-10150837.354453126,1565430.3390000015],[-10150837.354453126,1562984.3540953137],[-10153283.339357814,1562984.3540953137]]]]},"properties":{"x":4041,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1565430.3390000015],[-10153283.339357814,1567876.3239046894],[-10150837.354453126,1567876.3239046894],[-10150837.354453126,1565430.3390000015],[-10153283.339357814,1565430.3390000015]]]]},"properties":{"x":4041,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1567876.3239046894],[-10153283.339357814,1570322.3088093735],[-10150837.354453126,1570322.3088093735],[-10150837.354453126,1567876.3239046894],[-10153283.339357814,1567876.3239046894]]]]},"properties":{"x":4041,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1570322.3088093735],[-10153283.339357814,1572768.2937140614],[-10150837.354453126,1572768.2937140614],[-10150837.354453126,1570322.3088093735],[-10153283.339357814,1570322.3088093735]]]]},"properties":{"x":4041,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1572768.2937140614],[-10153283.339357814,1575214.2786187492],[-10150837.354453126,1575214.2786187492],[-10150837.354453126,1572768.2937140614],[-10153283.339357814,1572768.2937140614]]]]},"properties":{"x":4041,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1575214.2786187492],[-10153283.339357814,1577660.263523437],[-10150837.354453126,1577660.263523437],[-10150837.354453126,1575214.2786187492],[-10153283.339357814,1575214.2786187492]]]]},"properties":{"x":4041,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1577660.263523437],[-10153283.339357814,1580106.248428125],[-10150837.354453126,1580106.248428125],[-10150837.354453126,1577660.263523437],[-10153283.339357814,1577660.263523437]]]]},"properties":{"x":4041,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1580106.248428125],[-10153283.339357814,1582552.2333328128],[-10150837.354453126,1582552.2333328128],[-10150837.354453126,1580106.248428125],[-10153283.339357814,1580106.248428125]]]]},"properties":{"x":4041,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1582552.2333328128],[-10153283.339357814,1584998.2182375006],[-10150837.354453126,1584998.2182375006],[-10150837.354453126,1582552.2333328128],[-10153283.339357814,1582552.2333328128]]]]},"properties":{"x":4041,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1584998.2182375006],[-10153283.339357814,1587444.2031421885],[-10150837.354453126,1587444.2031421885],[-10150837.354453126,1584998.2182375006],[-10153283.339357814,1584998.2182375006]]]]},"properties":{"x":4041,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1587444.2031421885],[-10153283.339357814,1589890.1880468763],[-10150837.354453126,1589890.1880468763],[-10150837.354453126,1587444.2031421885],[-10153283.339357814,1587444.2031421885]]]]},"properties":{"x":4041,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1589890.1880468763],[-10153283.339357814,1592336.1729515642],[-10150837.354453126,1592336.1729515642],[-10150837.354453126,1589890.1880468763],[-10153283.339357814,1589890.1880468763]]]]},"properties":{"x":4041,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1592336.1729515642],[-10153283.339357814,1594782.1578562483],[-10150837.354453126,1594782.1578562483],[-10150837.354453126,1592336.1729515642],[-10153283.339357814,1592336.1729515642]]]]},"properties":{"x":4041,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1594782.1578562483],[-10153283.339357814,1597228.1427609362],[-10150837.354453126,1597228.1427609362],[-10150837.354453126,1594782.1578562483],[-10153283.339357814,1594782.1578562483]]]]},"properties":{"x":4041,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1597228.1427609362],[-10153283.339357814,1599674.127665624],[-10150837.354453126,1599674.127665624],[-10150837.354453126,1597228.1427609362],[-10153283.339357814,1597228.1427609362]]]]},"properties":{"x":4041,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1599674.127665624],[-10153283.339357814,1602120.1125703119],[-10150837.354453126,1602120.1125703119],[-10150837.354453126,1599674.127665624],[-10153283.339357814,1599674.127665624]]]]},"properties":{"x":4041,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1602120.1125703119],[-10153283.339357814,1604566.0974749997],[-10150837.354453126,1604566.0974749997],[-10150837.354453126,1602120.1125703119],[-10153283.339357814,1602120.1125703119]]]]},"properties":{"x":4041,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1604566.0974749997],[-10153283.339357814,1607012.0823796876],[-10150837.354453126,1607012.0823796876],[-10150837.354453126,1604566.0974749997],[-10153283.339357814,1604566.0974749997]]]]},"properties":{"x":4041,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1607012.0823796876],[-10153283.339357814,1609458.0672843754],[-10150837.354453126,1609458.0672843754],[-10150837.354453126,1607012.0823796876],[-10153283.339357814,1607012.0823796876]]]]},"properties":{"x":4041,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1609458.0672843754],[-10153283.339357814,1611904.0521890633],[-10150837.354453126,1611904.0521890633],[-10150837.354453126,1609458.0672843754],[-10153283.339357814,1609458.0672843754]]]]},"properties":{"x":4041,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1611904.0521890633],[-10153283.339357814,1614350.0370937511],[-10150837.354453126,1614350.0370937511],[-10150837.354453126,1611904.0521890633],[-10153283.339357814,1611904.0521890633]]]]},"properties":{"x":4041,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1614350.0370937511],[-10153283.339357814,1616796.021998439],[-10150837.354453126,1616796.021998439],[-10150837.354453126,1614350.0370937511],[-10153283.339357814,1614350.0370937511]]]]},"properties":{"x":4041,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1616796.021998439],[-10153283.339357814,1619242.0069031268],[-10150837.354453126,1619242.0069031268],[-10150837.354453126,1616796.021998439],[-10153283.339357814,1616796.021998439]]]]},"properties":{"x":4041,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1619242.0069031268],[-10153283.339357814,1621687.991807811],[-10150837.354453126,1621687.991807811],[-10150837.354453126,1619242.0069031268],[-10153283.339357814,1619242.0069031268]]]]},"properties":{"x":4041,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1621687.991807811],[-10153283.339357814,1624133.9767124988],[-10150837.354453126,1624133.9767124988],[-10150837.354453126,1621687.991807811],[-10153283.339357814,1621687.991807811]]]]},"properties":{"x":4041,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10153283.339357814,1624133.9767124988],[-10153283.339357814,1626579.9616171867],[-10150837.354453126,1626579.9616171867],[-10150837.354453126,1624133.9767124988],[-10153283.339357814,1624133.9767124988]]]]},"properties":{"x":4041,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1562984.3540953137],[-10150837.354453126,1565430.3390000015],[-10148391.369548438,1565430.3390000015],[-10148391.369548438,1562984.3540953137],[-10150837.354453126,1562984.3540953137]]]]},"properties":{"x":4042,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1565430.3390000015],[-10150837.354453126,1567876.3239046894],[-10148391.369548438,1567876.3239046894],[-10148391.369548438,1565430.3390000015],[-10150837.354453126,1565430.3390000015]]]]},"properties":{"x":4042,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1567876.3239046894],[-10150837.354453126,1570322.3088093735],[-10148391.369548438,1570322.3088093735],[-10148391.369548438,1567876.3239046894],[-10150837.354453126,1567876.3239046894]]]]},"properties":{"x":4042,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1570322.3088093735],[-10150837.354453126,1572768.2937140614],[-10148391.369548438,1572768.2937140614],[-10148391.369548438,1570322.3088093735],[-10150837.354453126,1570322.3088093735]]]]},"properties":{"x":4042,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1572768.2937140614],[-10150837.354453126,1575214.2786187492],[-10148391.369548438,1575214.2786187492],[-10148391.369548438,1572768.2937140614],[-10150837.354453126,1572768.2937140614]]]]},"properties":{"x":4042,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1575214.2786187492],[-10150837.354453126,1577660.263523437],[-10148391.369548438,1577660.263523437],[-10148391.369548438,1575214.2786187492],[-10150837.354453126,1575214.2786187492]]]]},"properties":{"x":4042,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1577660.263523437],[-10150837.354453126,1580106.248428125],[-10148391.369548438,1580106.248428125],[-10148391.369548438,1577660.263523437],[-10150837.354453126,1577660.263523437]]]]},"properties":{"x":4042,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1580106.248428125],[-10150837.354453126,1582552.2333328128],[-10148391.369548438,1582552.2333328128],[-10148391.369548438,1580106.248428125],[-10150837.354453126,1580106.248428125]]]]},"properties":{"x":4042,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1582552.2333328128],[-10150837.354453126,1584998.2182375006],[-10148391.369548438,1584998.2182375006],[-10148391.369548438,1582552.2333328128],[-10150837.354453126,1582552.2333328128]]]]},"properties":{"x":4042,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1584998.2182375006],[-10150837.354453126,1587444.2031421885],[-10148391.369548438,1587444.2031421885],[-10148391.369548438,1584998.2182375006],[-10150837.354453126,1584998.2182375006]]]]},"properties":{"x":4042,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1587444.2031421885],[-10150837.354453126,1589890.1880468763],[-10148391.369548438,1589890.1880468763],[-10148391.369548438,1587444.2031421885],[-10150837.354453126,1587444.2031421885]]]]},"properties":{"x":4042,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1589890.1880468763],[-10150837.354453126,1592336.1729515642],[-10148391.369548438,1592336.1729515642],[-10148391.369548438,1589890.1880468763],[-10150837.354453126,1589890.1880468763]]]]},"properties":{"x":4042,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1592336.1729515642],[-10150837.354453126,1594782.1578562483],[-10148391.369548438,1594782.1578562483],[-10148391.369548438,1592336.1729515642],[-10150837.354453126,1592336.1729515642]]]]},"properties":{"x":4042,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1594782.1578562483],[-10150837.354453126,1597228.1427609362],[-10148391.369548438,1597228.1427609362],[-10148391.369548438,1594782.1578562483],[-10150837.354453126,1594782.1578562483]]]]},"properties":{"x":4042,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1597228.1427609362],[-10150837.354453126,1599674.127665624],[-10148391.369548438,1599674.127665624],[-10148391.369548438,1597228.1427609362],[-10150837.354453126,1597228.1427609362]]]]},"properties":{"x":4042,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1599674.127665624],[-10150837.354453126,1602120.1125703119],[-10148391.369548438,1602120.1125703119],[-10148391.369548438,1599674.127665624],[-10150837.354453126,1599674.127665624]]]]},"properties":{"x":4042,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1602120.1125703119],[-10150837.354453126,1604566.0974749997],[-10148391.369548438,1604566.0974749997],[-10148391.369548438,1602120.1125703119],[-10150837.354453126,1602120.1125703119]]]]},"properties":{"x":4042,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1604566.0974749997],[-10150837.354453126,1607012.0823796876],[-10148391.369548438,1607012.0823796876],[-10148391.369548438,1604566.0974749997],[-10150837.354453126,1604566.0974749997]]]]},"properties":{"x":4042,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1607012.0823796876],[-10150837.354453126,1609458.0672843754],[-10148391.369548438,1609458.0672843754],[-10148391.369548438,1607012.0823796876],[-10150837.354453126,1607012.0823796876]]]]},"properties":{"x":4042,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1609458.0672843754],[-10150837.354453126,1611904.0521890633],[-10148391.369548438,1611904.0521890633],[-10148391.369548438,1609458.0672843754],[-10150837.354453126,1609458.0672843754]]]]},"properties":{"x":4042,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1611904.0521890633],[-10150837.354453126,1614350.0370937511],[-10148391.369548438,1614350.0370937511],[-10148391.369548438,1611904.0521890633],[-10150837.354453126,1611904.0521890633]]]]},"properties":{"x":4042,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1614350.0370937511],[-10150837.354453126,1616796.021998439],[-10148391.369548438,1616796.021998439],[-10148391.369548438,1614350.0370937511],[-10150837.354453126,1614350.0370937511]]]]},"properties":{"x":4042,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1616796.021998439],[-10150837.354453126,1619242.0069031268],[-10148391.369548438,1619242.0069031268],[-10148391.369548438,1616796.021998439],[-10150837.354453126,1616796.021998439]]]]},"properties":{"x":4042,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1619242.0069031268],[-10150837.354453126,1621687.991807811],[-10148391.369548438,1621687.991807811],[-10148391.369548438,1619242.0069031268],[-10150837.354453126,1619242.0069031268]]]]},"properties":{"x":4042,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1621687.991807811],[-10150837.354453126,1624133.9767124988],[-10148391.369548438,1624133.9767124988],[-10148391.369548438,1621687.991807811],[-10150837.354453126,1621687.991807811]]]]},"properties":{"x":4042,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10150837.354453126,1624133.9767124988],[-10150837.354453126,1626579.9616171867],[-10148391.369548438,1626579.9616171867],[-10148391.369548438,1624133.9767124988],[-10150837.354453126,1624133.9767124988]]]]},"properties":{"x":4042,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1562984.3540953137],[-10148391.369548438,1565430.3390000015],[-10145945.38464375,1565430.3390000015],[-10145945.38464375,1562984.3540953137],[-10148391.369548438,1562984.3540953137]]]]},"properties":{"x":4043,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1565430.3390000015],[-10148391.369548438,1567876.3239046894],[-10145945.38464375,1567876.3239046894],[-10145945.38464375,1565430.3390000015],[-10148391.369548438,1565430.3390000015]]]]},"properties":{"x":4043,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1567876.3239046894],[-10148391.369548438,1570322.3088093735],[-10145945.38464375,1570322.3088093735],[-10145945.38464375,1567876.3239046894],[-10148391.369548438,1567876.3239046894]]]]},"properties":{"x":4043,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1570322.3088093735],[-10148391.369548438,1572768.2937140614],[-10145945.38464375,1572768.2937140614],[-10145945.38464375,1570322.3088093735],[-10148391.369548438,1570322.3088093735]]]]},"properties":{"x":4043,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1572768.2937140614],[-10148391.369548438,1575214.2786187492],[-10145945.38464375,1575214.2786187492],[-10145945.38464375,1572768.2937140614],[-10148391.369548438,1572768.2937140614]]]]},"properties":{"x":4043,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1575214.2786187492],[-10148391.369548438,1577660.263523437],[-10145945.38464375,1577660.263523437],[-10145945.38464375,1575214.2786187492],[-10148391.369548438,1575214.2786187492]]]]},"properties":{"x":4043,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1577660.263523437],[-10148391.369548438,1580106.248428125],[-10145945.38464375,1580106.248428125],[-10145945.38464375,1577660.263523437],[-10148391.369548438,1577660.263523437]]]]},"properties":{"x":4043,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1580106.248428125],[-10148391.369548438,1582552.2333328128],[-10145945.38464375,1582552.2333328128],[-10145945.38464375,1580106.248428125],[-10148391.369548438,1580106.248428125]]]]},"properties":{"x":4043,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1582552.2333328128],[-10148391.369548438,1584998.2182375006],[-10145945.38464375,1584998.2182375006],[-10145945.38464375,1582552.2333328128],[-10148391.369548438,1582552.2333328128]]]]},"properties":{"x":4043,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1584998.2182375006],[-10148391.369548438,1587444.2031421885],[-10145945.38464375,1587444.2031421885],[-10145945.38464375,1584998.2182375006],[-10148391.369548438,1584998.2182375006]]]]},"properties":{"x":4043,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1587444.2031421885],[-10148391.369548438,1589890.1880468763],[-10145945.38464375,1589890.1880468763],[-10145945.38464375,1587444.2031421885],[-10148391.369548438,1587444.2031421885]]]]},"properties":{"x":4043,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1589890.1880468763],[-10148391.369548438,1592336.1729515642],[-10145945.38464375,1592336.1729515642],[-10145945.38464375,1589890.1880468763],[-10148391.369548438,1589890.1880468763]]]]},"properties":{"x":4043,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1592336.1729515642],[-10148391.369548438,1594782.1578562483],[-10145945.38464375,1594782.1578562483],[-10145945.38464375,1592336.1729515642],[-10148391.369548438,1592336.1729515642]]]]},"properties":{"x":4043,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1594782.1578562483],[-10148391.369548438,1597228.1427609362],[-10145945.38464375,1597228.1427609362],[-10145945.38464375,1594782.1578562483],[-10148391.369548438,1594782.1578562483]]]]},"properties":{"x":4043,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1597228.1427609362],[-10148391.369548438,1599674.127665624],[-10145945.38464375,1599674.127665624],[-10145945.38464375,1597228.1427609362],[-10148391.369548438,1597228.1427609362]]]]},"properties":{"x":4043,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1599674.127665624],[-10148391.369548438,1602120.1125703119],[-10145945.38464375,1602120.1125703119],[-10145945.38464375,1599674.127665624],[-10148391.369548438,1599674.127665624]]]]},"properties":{"x":4043,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1602120.1125703119],[-10148391.369548438,1604566.0974749997],[-10145945.38464375,1604566.0974749997],[-10145945.38464375,1602120.1125703119],[-10148391.369548438,1602120.1125703119]]]]},"properties":{"x":4043,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1604566.0974749997],[-10148391.369548438,1607012.0823796876],[-10145945.38464375,1607012.0823796876],[-10145945.38464375,1604566.0974749997],[-10148391.369548438,1604566.0974749997]]]]},"properties":{"x":4043,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1607012.0823796876],[-10148391.369548438,1609458.0672843754],[-10145945.38464375,1609458.0672843754],[-10145945.38464375,1607012.0823796876],[-10148391.369548438,1607012.0823796876]]]]},"properties":{"x":4043,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1609458.0672843754],[-10148391.369548438,1611904.0521890633],[-10145945.38464375,1611904.0521890633],[-10145945.38464375,1609458.0672843754],[-10148391.369548438,1609458.0672843754]]]]},"properties":{"x":4043,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1611904.0521890633],[-10148391.369548438,1614350.0370937511],[-10145945.38464375,1614350.0370937511],[-10145945.38464375,1611904.0521890633],[-10148391.369548438,1611904.0521890633]]]]},"properties":{"x":4043,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1614350.0370937511],[-10148391.369548438,1616796.021998439],[-10145945.38464375,1616796.021998439],[-10145945.38464375,1614350.0370937511],[-10148391.369548438,1614350.0370937511]]]]},"properties":{"x":4043,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1616796.021998439],[-10148391.369548438,1619242.0069031268],[-10145945.38464375,1619242.0069031268],[-10145945.38464375,1616796.021998439],[-10148391.369548438,1616796.021998439]]]]},"properties":{"x":4043,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1619242.0069031268],[-10148391.369548438,1621687.991807811],[-10145945.38464375,1621687.991807811],[-10145945.38464375,1619242.0069031268],[-10148391.369548438,1619242.0069031268]]]]},"properties":{"x":4043,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1621687.991807811],[-10148391.369548438,1624133.9767124988],[-10145945.38464375,1624133.9767124988],[-10145945.38464375,1621687.991807811],[-10148391.369548438,1621687.991807811]]]]},"properties":{"x":4043,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10148391.369548438,1624133.9767124988],[-10148391.369548438,1626579.9616171867],[-10145945.38464375,1626579.9616171867],[-10145945.38464375,1624133.9767124988],[-10148391.369548438,1624133.9767124988]]]]},"properties":{"x":4043,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1562984.3540953137],[-10145945.38464375,1565430.3390000015],[-10143499.399739062,1565430.3390000015],[-10143499.399739062,1562984.3540953137],[-10145945.38464375,1562984.3540953137]]]]},"properties":{"x":4044,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1565430.3390000015],[-10145945.38464375,1567876.3239046894],[-10143499.399739062,1567876.3239046894],[-10143499.399739062,1565430.3390000015],[-10145945.38464375,1565430.3390000015]]]]},"properties":{"x":4044,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1567876.3239046894],[-10145945.38464375,1570322.3088093735],[-10143499.399739062,1570322.3088093735],[-10143499.399739062,1567876.3239046894],[-10145945.38464375,1567876.3239046894]]]]},"properties":{"x":4044,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1570322.3088093735],[-10145945.38464375,1572768.2937140614],[-10143499.399739062,1572768.2937140614],[-10143499.399739062,1570322.3088093735],[-10145945.38464375,1570322.3088093735]]]]},"properties":{"x":4044,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1572768.2937140614],[-10145945.38464375,1575214.2786187492],[-10143499.399739062,1575214.2786187492],[-10143499.399739062,1572768.2937140614],[-10145945.38464375,1572768.2937140614]]]]},"properties":{"x":4044,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1575214.2786187492],[-10145945.38464375,1577660.263523437],[-10143499.399739062,1577660.263523437],[-10143499.399739062,1575214.2786187492],[-10145945.38464375,1575214.2786187492]]]]},"properties":{"x":4044,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1577660.263523437],[-10145945.38464375,1580106.248428125],[-10143499.399739062,1580106.248428125],[-10143499.399739062,1577660.263523437],[-10145945.38464375,1577660.263523437]]]]},"properties":{"x":4044,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1580106.248428125],[-10145945.38464375,1582552.2333328128],[-10143499.399739062,1582552.2333328128],[-10143499.399739062,1580106.248428125],[-10145945.38464375,1580106.248428125]]]]},"properties":{"x":4044,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1582552.2333328128],[-10145945.38464375,1584998.2182375006],[-10143499.399739062,1584998.2182375006],[-10143499.399739062,1582552.2333328128],[-10145945.38464375,1582552.2333328128]]]]},"properties":{"x":4044,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1584998.2182375006],[-10145945.38464375,1587444.2031421885],[-10143499.399739062,1587444.2031421885],[-10143499.399739062,1584998.2182375006],[-10145945.38464375,1584998.2182375006]]]]},"properties":{"x":4044,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1587444.2031421885],[-10145945.38464375,1589890.1880468763],[-10143499.399739062,1589890.1880468763],[-10143499.399739062,1587444.2031421885],[-10145945.38464375,1587444.2031421885]]]]},"properties":{"x":4044,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1589890.1880468763],[-10145945.38464375,1592336.1729515642],[-10143499.399739062,1592336.1729515642],[-10143499.399739062,1589890.1880468763],[-10145945.38464375,1589890.1880468763]]]]},"properties":{"x":4044,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1592336.1729515642],[-10145945.38464375,1594782.1578562483],[-10143499.399739062,1594782.1578562483],[-10143499.399739062,1592336.1729515642],[-10145945.38464375,1592336.1729515642]]]]},"properties":{"x":4044,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1594782.1578562483],[-10145945.38464375,1597228.1427609362],[-10143499.399739062,1597228.1427609362],[-10143499.399739062,1594782.1578562483],[-10145945.38464375,1594782.1578562483]]]]},"properties":{"x":4044,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1597228.1427609362],[-10145945.38464375,1599674.127665624],[-10143499.399739062,1599674.127665624],[-10143499.399739062,1597228.1427609362],[-10145945.38464375,1597228.1427609362]]]]},"properties":{"x":4044,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1599674.127665624],[-10145945.38464375,1602120.1125703119],[-10143499.399739062,1602120.1125703119],[-10143499.399739062,1599674.127665624],[-10145945.38464375,1599674.127665624]]]]},"properties":{"x":4044,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1602120.1125703119],[-10145945.38464375,1604566.0974749997],[-10143499.399739062,1604566.0974749997],[-10143499.399739062,1602120.1125703119],[-10145945.38464375,1602120.1125703119]]]]},"properties":{"x":4044,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1604566.0974749997],[-10145945.38464375,1607012.0823796876],[-10143499.399739062,1607012.0823796876],[-10143499.399739062,1604566.0974749997],[-10145945.38464375,1604566.0974749997]]]]},"properties":{"x":4044,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1607012.0823796876],[-10145945.38464375,1609458.0672843754],[-10143499.399739062,1609458.0672843754],[-10143499.399739062,1607012.0823796876],[-10145945.38464375,1607012.0823796876]]]]},"properties":{"x":4044,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1609458.0672843754],[-10145945.38464375,1611904.0521890633],[-10143499.399739062,1611904.0521890633],[-10143499.399739062,1609458.0672843754],[-10145945.38464375,1609458.0672843754]]]]},"properties":{"x":4044,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1611904.0521890633],[-10145945.38464375,1614350.0370937511],[-10143499.399739062,1614350.0370937511],[-10143499.399739062,1611904.0521890633],[-10145945.38464375,1611904.0521890633]]]]},"properties":{"x":4044,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1614350.0370937511],[-10145945.38464375,1616796.021998439],[-10143499.399739062,1616796.021998439],[-10143499.399739062,1614350.0370937511],[-10145945.38464375,1614350.0370937511]]]]},"properties":{"x":4044,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1616796.021998439],[-10145945.38464375,1619242.0069031268],[-10143499.399739062,1619242.0069031268],[-10143499.399739062,1616796.021998439],[-10145945.38464375,1616796.021998439]]]]},"properties":{"x":4044,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1619242.0069031268],[-10145945.38464375,1621687.991807811],[-10143499.399739062,1621687.991807811],[-10143499.399739062,1619242.0069031268],[-10145945.38464375,1619242.0069031268]]]]},"properties":{"x":4044,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1621687.991807811],[-10145945.38464375,1624133.9767124988],[-10143499.399739062,1624133.9767124988],[-10143499.399739062,1621687.991807811],[-10145945.38464375,1621687.991807811]]]]},"properties":{"x":4044,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10145945.38464375,1624133.9767124988],[-10145945.38464375,1626579.9616171867],[-10143499.399739062,1626579.9616171867],[-10143499.399739062,1624133.9767124988],[-10145945.38464375,1624133.9767124988]]]]},"properties":{"x":4044,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1562984.3540953137],[-10143499.399739062,1565430.3390000015],[-10141053.414834376,1565430.3390000015],[-10141053.414834376,1562984.3540953137],[-10143499.399739062,1562984.3540953137]]]]},"properties":{"x":4045,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1565430.3390000015],[-10143499.399739062,1567876.3239046894],[-10141053.414834376,1567876.3239046894],[-10141053.414834376,1565430.3390000015],[-10143499.399739062,1565430.3390000015]]]]},"properties":{"x":4045,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1567876.3239046894],[-10143499.399739062,1570322.3088093735],[-10141053.414834376,1570322.3088093735],[-10141053.414834376,1567876.3239046894],[-10143499.399739062,1567876.3239046894]]]]},"properties":{"x":4045,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1570322.3088093735],[-10143499.399739062,1572768.2937140614],[-10141053.414834376,1572768.2937140614],[-10141053.414834376,1570322.3088093735],[-10143499.399739062,1570322.3088093735]]]]},"properties":{"x":4045,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1572768.2937140614],[-10143499.399739062,1575214.2786187492],[-10141053.414834376,1575214.2786187492],[-10141053.414834376,1572768.2937140614],[-10143499.399739062,1572768.2937140614]]]]},"properties":{"x":4045,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1575214.2786187492],[-10143499.399739062,1577660.263523437],[-10141053.414834376,1577660.263523437],[-10141053.414834376,1575214.2786187492],[-10143499.399739062,1575214.2786187492]]]]},"properties":{"x":4045,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1577660.263523437],[-10143499.399739062,1580106.248428125],[-10141053.414834376,1580106.248428125],[-10141053.414834376,1577660.263523437],[-10143499.399739062,1577660.263523437]]]]},"properties":{"x":4045,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1580106.248428125],[-10143499.399739062,1582552.2333328128],[-10141053.414834376,1582552.2333328128],[-10141053.414834376,1580106.248428125],[-10143499.399739062,1580106.248428125]]]]},"properties":{"x":4045,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1582552.2333328128],[-10143499.399739062,1584998.2182375006],[-10141053.414834376,1584998.2182375006],[-10141053.414834376,1582552.2333328128],[-10143499.399739062,1582552.2333328128]]]]},"properties":{"x":4045,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1584998.2182375006],[-10143499.399739062,1587444.2031421885],[-10141053.414834376,1587444.2031421885],[-10141053.414834376,1584998.2182375006],[-10143499.399739062,1584998.2182375006]]]]},"properties":{"x":4045,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1587444.2031421885],[-10143499.399739062,1589890.1880468763],[-10141053.414834376,1589890.1880468763],[-10141053.414834376,1587444.2031421885],[-10143499.399739062,1587444.2031421885]]]]},"properties":{"x":4045,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1589890.1880468763],[-10143499.399739062,1592336.1729515642],[-10141053.414834376,1592336.1729515642],[-10141053.414834376,1589890.1880468763],[-10143499.399739062,1589890.1880468763]]]]},"properties":{"x":4045,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1592336.1729515642],[-10143499.399739062,1594782.1578562483],[-10141053.414834376,1594782.1578562483],[-10141053.414834376,1592336.1729515642],[-10143499.399739062,1592336.1729515642]]]]},"properties":{"x":4045,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1594782.1578562483],[-10143499.399739062,1597228.1427609362],[-10141053.414834376,1597228.1427609362],[-10141053.414834376,1594782.1578562483],[-10143499.399739062,1594782.1578562483]]]]},"properties":{"x":4045,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1597228.1427609362],[-10143499.399739062,1599674.127665624],[-10141053.414834376,1599674.127665624],[-10141053.414834376,1597228.1427609362],[-10143499.399739062,1597228.1427609362]]]]},"properties":{"x":4045,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1599674.127665624],[-10143499.399739062,1602120.1125703119],[-10141053.414834376,1602120.1125703119],[-10141053.414834376,1599674.127665624],[-10143499.399739062,1599674.127665624]]]]},"properties":{"x":4045,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1602120.1125703119],[-10143499.399739062,1604566.0974749997],[-10141053.414834376,1604566.0974749997],[-10141053.414834376,1602120.1125703119],[-10143499.399739062,1602120.1125703119]]]]},"properties":{"x":4045,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1604566.0974749997],[-10143499.399739062,1607012.0823796876],[-10141053.414834376,1607012.0823796876],[-10141053.414834376,1604566.0974749997],[-10143499.399739062,1604566.0974749997]]]]},"properties":{"x":4045,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1607012.0823796876],[-10143499.399739062,1609458.0672843754],[-10141053.414834376,1609458.0672843754],[-10141053.414834376,1607012.0823796876],[-10143499.399739062,1607012.0823796876]]]]},"properties":{"x":4045,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1609458.0672843754],[-10143499.399739062,1611904.0521890633],[-10141053.414834376,1611904.0521890633],[-10141053.414834376,1609458.0672843754],[-10143499.399739062,1609458.0672843754]]]]},"properties":{"x":4045,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1611904.0521890633],[-10143499.399739062,1614350.0370937511],[-10141053.414834376,1614350.0370937511],[-10141053.414834376,1611904.0521890633],[-10143499.399739062,1611904.0521890633]]]]},"properties":{"x":4045,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1614350.0370937511],[-10143499.399739062,1616796.021998439],[-10141053.414834376,1616796.021998439],[-10141053.414834376,1614350.0370937511],[-10143499.399739062,1614350.0370937511]]]]},"properties":{"x":4045,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1616796.021998439],[-10143499.399739062,1619242.0069031268],[-10141053.414834376,1619242.0069031268],[-10141053.414834376,1616796.021998439],[-10143499.399739062,1616796.021998439]]]]},"properties":{"x":4045,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1619242.0069031268],[-10143499.399739062,1621687.991807811],[-10141053.414834376,1621687.991807811],[-10141053.414834376,1619242.0069031268],[-10143499.399739062,1619242.0069031268]]]]},"properties":{"x":4045,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1621687.991807811],[-10143499.399739062,1624133.9767124988],[-10141053.414834376,1624133.9767124988],[-10141053.414834376,1621687.991807811],[-10143499.399739062,1621687.991807811]]]]},"properties":{"x":4045,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10143499.399739062,1624133.9767124988],[-10143499.399739062,1626579.9616171867],[-10141053.414834376,1626579.9616171867],[-10141053.414834376,1624133.9767124988],[-10143499.399739062,1624133.9767124988]]]]},"properties":{"x":4045,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1562984.3540953137],[-10141053.414834376,1565430.3390000015],[-10138607.429929689,1565430.3390000015],[-10138607.429929689,1562984.3540953137],[-10141053.414834376,1562984.3540953137]]]]},"properties":{"x":4046,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1565430.3390000015],[-10141053.414834376,1567876.3239046894],[-10138607.429929689,1567876.3239046894],[-10138607.429929689,1565430.3390000015],[-10141053.414834376,1565430.3390000015]]]]},"properties":{"x":4046,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1567876.3239046894],[-10141053.414834376,1570322.3088093735],[-10138607.429929689,1570322.3088093735],[-10138607.429929689,1567876.3239046894],[-10141053.414834376,1567876.3239046894]]]]},"properties":{"x":4046,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1570322.3088093735],[-10141053.414834376,1572768.2937140614],[-10138607.429929689,1572768.2937140614],[-10138607.429929689,1570322.3088093735],[-10141053.414834376,1570322.3088093735]]]]},"properties":{"x":4046,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1572768.2937140614],[-10141053.414834376,1575214.2786187492],[-10138607.429929689,1575214.2786187492],[-10138607.429929689,1572768.2937140614],[-10141053.414834376,1572768.2937140614]]]]},"properties":{"x":4046,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1575214.2786187492],[-10141053.414834376,1577660.263523437],[-10138607.429929689,1577660.263523437],[-10138607.429929689,1575214.2786187492],[-10141053.414834376,1575214.2786187492]]]]},"properties":{"x":4046,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1577660.263523437],[-10141053.414834376,1580106.248428125],[-10138607.429929689,1580106.248428125],[-10138607.429929689,1577660.263523437],[-10141053.414834376,1577660.263523437]]]]},"properties":{"x":4046,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1580106.248428125],[-10141053.414834376,1582552.2333328128],[-10138607.429929689,1582552.2333328128],[-10138607.429929689,1580106.248428125],[-10141053.414834376,1580106.248428125]]]]},"properties":{"x":4046,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1582552.2333328128],[-10141053.414834376,1584998.2182375006],[-10138607.429929689,1584998.2182375006],[-10138607.429929689,1582552.2333328128],[-10141053.414834376,1582552.2333328128]]]]},"properties":{"x":4046,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1584998.2182375006],[-10141053.414834376,1587444.2031421885],[-10138607.429929689,1587444.2031421885],[-10138607.429929689,1584998.2182375006],[-10141053.414834376,1584998.2182375006]]]]},"properties":{"x":4046,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1587444.2031421885],[-10141053.414834376,1589890.1880468763],[-10138607.429929689,1589890.1880468763],[-10138607.429929689,1587444.2031421885],[-10141053.414834376,1587444.2031421885]]]]},"properties":{"x":4046,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1589890.1880468763],[-10141053.414834376,1592336.1729515642],[-10138607.429929689,1592336.1729515642],[-10138607.429929689,1589890.1880468763],[-10141053.414834376,1589890.1880468763]]]]},"properties":{"x":4046,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1592336.1729515642],[-10141053.414834376,1594782.1578562483],[-10138607.429929689,1594782.1578562483],[-10138607.429929689,1592336.1729515642],[-10141053.414834376,1592336.1729515642]]]]},"properties":{"x":4046,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1594782.1578562483],[-10141053.414834376,1597228.1427609362],[-10138607.429929689,1597228.1427609362],[-10138607.429929689,1594782.1578562483],[-10141053.414834376,1594782.1578562483]]]]},"properties":{"x":4046,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1597228.1427609362],[-10141053.414834376,1599674.127665624],[-10138607.429929689,1599674.127665624],[-10138607.429929689,1597228.1427609362],[-10141053.414834376,1597228.1427609362]]]]},"properties":{"x":4046,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1599674.127665624],[-10141053.414834376,1602120.1125703119],[-10138607.429929689,1602120.1125703119],[-10138607.429929689,1599674.127665624],[-10141053.414834376,1599674.127665624]]]]},"properties":{"x":4046,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1602120.1125703119],[-10141053.414834376,1604566.0974749997],[-10138607.429929689,1604566.0974749997],[-10138607.429929689,1602120.1125703119],[-10141053.414834376,1602120.1125703119]]]]},"properties":{"x":4046,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1604566.0974749997],[-10141053.414834376,1607012.0823796876],[-10138607.429929689,1607012.0823796876],[-10138607.429929689,1604566.0974749997],[-10141053.414834376,1604566.0974749997]]]]},"properties":{"x":4046,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1607012.0823796876],[-10141053.414834376,1609458.0672843754],[-10138607.429929689,1609458.0672843754],[-10138607.429929689,1607012.0823796876],[-10141053.414834376,1607012.0823796876]]]]},"properties":{"x":4046,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1609458.0672843754],[-10141053.414834376,1611904.0521890633],[-10138607.429929689,1611904.0521890633],[-10138607.429929689,1609458.0672843754],[-10141053.414834376,1609458.0672843754]]]]},"properties":{"x":4046,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1611904.0521890633],[-10141053.414834376,1614350.0370937511],[-10138607.429929689,1614350.0370937511],[-10138607.429929689,1611904.0521890633],[-10141053.414834376,1611904.0521890633]]]]},"properties":{"x":4046,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1614350.0370937511],[-10141053.414834376,1616796.021998439],[-10138607.429929689,1616796.021998439],[-10138607.429929689,1614350.0370937511],[-10141053.414834376,1614350.0370937511]]]]},"properties":{"x":4046,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1616796.021998439],[-10141053.414834376,1619242.0069031268],[-10138607.429929689,1619242.0069031268],[-10138607.429929689,1616796.021998439],[-10141053.414834376,1616796.021998439]]]]},"properties":{"x":4046,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1619242.0069031268],[-10141053.414834376,1621687.991807811],[-10138607.429929689,1621687.991807811],[-10138607.429929689,1619242.0069031268],[-10141053.414834376,1619242.0069031268]]]]},"properties":{"x":4046,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1621687.991807811],[-10141053.414834376,1624133.9767124988],[-10138607.429929689,1624133.9767124988],[-10138607.429929689,1621687.991807811],[-10141053.414834376,1621687.991807811]]]]},"properties":{"x":4046,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10141053.414834376,1624133.9767124988],[-10141053.414834376,1626579.9616171867],[-10138607.429929689,1626579.9616171867],[-10138607.429929689,1624133.9767124988],[-10141053.414834376,1624133.9767124988]]]]},"properties":{"x":4046,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1562984.3540953137],[-10138607.429929689,1565430.3390000015],[-10136161.445025,1565430.3390000015],[-10136161.445025,1562984.3540953137],[-10138607.429929689,1562984.3540953137]]]]},"properties":{"x":4047,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1565430.3390000015],[-10138607.429929689,1567876.3239046894],[-10136161.445025,1567876.3239046894],[-10136161.445025,1565430.3390000015],[-10138607.429929689,1565430.3390000015]]]]},"properties":{"x":4047,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1567876.3239046894],[-10138607.429929689,1570322.3088093735],[-10136161.445025,1570322.3088093735],[-10136161.445025,1567876.3239046894],[-10138607.429929689,1567876.3239046894]]]]},"properties":{"x":4047,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1570322.3088093735],[-10138607.429929689,1572768.2937140614],[-10136161.445025,1572768.2937140614],[-10136161.445025,1570322.3088093735],[-10138607.429929689,1570322.3088093735]]]]},"properties":{"x":4047,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1572768.2937140614],[-10138607.429929689,1575214.2786187492],[-10136161.445025,1575214.2786187492],[-10136161.445025,1572768.2937140614],[-10138607.429929689,1572768.2937140614]]]]},"properties":{"x":4047,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1575214.2786187492],[-10138607.429929689,1577660.263523437],[-10136161.445025,1577660.263523437],[-10136161.445025,1575214.2786187492],[-10138607.429929689,1575214.2786187492]]]]},"properties":{"x":4047,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1577660.263523437],[-10138607.429929689,1580106.248428125],[-10136161.445025,1580106.248428125],[-10136161.445025,1577660.263523437],[-10138607.429929689,1577660.263523437]]]]},"properties":{"x":4047,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1580106.248428125],[-10138607.429929689,1582552.2333328128],[-10136161.445025,1582552.2333328128],[-10136161.445025,1580106.248428125],[-10138607.429929689,1580106.248428125]]]]},"properties":{"x":4047,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1582552.2333328128],[-10138607.429929689,1584998.2182375006],[-10136161.445025,1584998.2182375006],[-10136161.445025,1582552.2333328128],[-10138607.429929689,1582552.2333328128]]]]},"properties":{"x":4047,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1584998.2182375006],[-10138607.429929689,1587444.2031421885],[-10136161.445025,1587444.2031421885],[-10136161.445025,1584998.2182375006],[-10138607.429929689,1584998.2182375006]]]]},"properties":{"x":4047,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1587444.2031421885],[-10138607.429929689,1589890.1880468763],[-10136161.445025,1589890.1880468763],[-10136161.445025,1587444.2031421885],[-10138607.429929689,1587444.2031421885]]]]},"properties":{"x":4047,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1589890.1880468763],[-10138607.429929689,1592336.1729515642],[-10136161.445025,1592336.1729515642],[-10136161.445025,1589890.1880468763],[-10138607.429929689,1589890.1880468763]]]]},"properties":{"x":4047,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1592336.1729515642],[-10138607.429929689,1594782.1578562483],[-10136161.445025,1594782.1578562483],[-10136161.445025,1592336.1729515642],[-10138607.429929689,1592336.1729515642]]]]},"properties":{"x":4047,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1594782.1578562483],[-10138607.429929689,1597228.1427609362],[-10136161.445025,1597228.1427609362],[-10136161.445025,1594782.1578562483],[-10138607.429929689,1594782.1578562483]]]]},"properties":{"x":4047,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1597228.1427609362],[-10138607.429929689,1599674.127665624],[-10136161.445025,1599674.127665624],[-10136161.445025,1597228.1427609362],[-10138607.429929689,1597228.1427609362]]]]},"properties":{"x":4047,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1599674.127665624],[-10138607.429929689,1602120.1125703119],[-10136161.445025,1602120.1125703119],[-10136161.445025,1599674.127665624],[-10138607.429929689,1599674.127665624]]]]},"properties":{"x":4047,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1602120.1125703119],[-10138607.429929689,1604566.0974749997],[-10136161.445025,1604566.0974749997],[-10136161.445025,1602120.1125703119],[-10138607.429929689,1602120.1125703119]]]]},"properties":{"x":4047,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1604566.0974749997],[-10138607.429929689,1607012.0823796876],[-10136161.445025,1607012.0823796876],[-10136161.445025,1604566.0974749997],[-10138607.429929689,1604566.0974749997]]]]},"properties":{"x":4047,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1607012.0823796876],[-10138607.429929689,1609458.0672843754],[-10136161.445025,1609458.0672843754],[-10136161.445025,1607012.0823796876],[-10138607.429929689,1607012.0823796876]]]]},"properties":{"x":4047,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1609458.0672843754],[-10138607.429929689,1611904.0521890633],[-10136161.445025,1611904.0521890633],[-10136161.445025,1609458.0672843754],[-10138607.429929689,1609458.0672843754]]]]},"properties":{"x":4047,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1611904.0521890633],[-10138607.429929689,1614350.0370937511],[-10136161.445025,1614350.0370937511],[-10136161.445025,1611904.0521890633],[-10138607.429929689,1611904.0521890633]]]]},"properties":{"x":4047,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1614350.0370937511],[-10138607.429929689,1616796.021998439],[-10136161.445025,1616796.021998439],[-10136161.445025,1614350.0370937511],[-10138607.429929689,1614350.0370937511]]]]},"properties":{"x":4047,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1616796.021998439],[-10138607.429929689,1619242.0069031268],[-10136161.445025,1619242.0069031268],[-10136161.445025,1616796.021998439],[-10138607.429929689,1616796.021998439]]]]},"properties":{"x":4047,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1619242.0069031268],[-10138607.429929689,1621687.991807811],[-10136161.445025,1621687.991807811],[-10136161.445025,1619242.0069031268],[-10138607.429929689,1619242.0069031268]]]]},"properties":{"x":4047,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1621687.991807811],[-10138607.429929689,1624133.9767124988],[-10136161.445025,1624133.9767124988],[-10136161.445025,1621687.991807811],[-10138607.429929689,1621687.991807811]]]]},"properties":{"x":4047,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10138607.429929689,1624133.9767124988],[-10138607.429929689,1626579.9616171867],[-10136161.445025,1626579.9616171867],[-10136161.445025,1624133.9767124988],[-10138607.429929689,1624133.9767124988]]]]},"properties":{"x":4047,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1562984.3540953137],[-10136161.445025,1565430.3390000015],[-10133715.460120313,1565430.3390000015],[-10133715.460120313,1562984.3540953137],[-10136161.445025,1562984.3540953137]]]]},"properties":{"x":4048,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1565430.3390000015],[-10136161.445025,1567876.3239046894],[-10133715.460120313,1567876.3239046894],[-10133715.460120313,1565430.3390000015],[-10136161.445025,1565430.3390000015]]]]},"properties":{"x":4048,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1567876.3239046894],[-10136161.445025,1570322.3088093735],[-10133715.460120313,1570322.3088093735],[-10133715.460120313,1567876.3239046894],[-10136161.445025,1567876.3239046894]]]]},"properties":{"x":4048,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1570322.3088093735],[-10136161.445025,1572768.2937140614],[-10133715.460120313,1572768.2937140614],[-10133715.460120313,1570322.3088093735],[-10136161.445025,1570322.3088093735]]]]},"properties":{"x":4048,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1572768.2937140614],[-10136161.445025,1575214.2786187492],[-10133715.460120313,1575214.2786187492],[-10133715.460120313,1572768.2937140614],[-10136161.445025,1572768.2937140614]]]]},"properties":{"x":4048,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1575214.2786187492],[-10136161.445025,1577660.263523437],[-10133715.460120313,1577660.263523437],[-10133715.460120313,1575214.2786187492],[-10136161.445025,1575214.2786187492]]]]},"properties":{"x":4048,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1577660.263523437],[-10136161.445025,1580106.248428125],[-10133715.460120313,1580106.248428125],[-10133715.460120313,1577660.263523437],[-10136161.445025,1577660.263523437]]]]},"properties":{"x":4048,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1580106.248428125],[-10136161.445025,1582552.2333328128],[-10133715.460120313,1582552.2333328128],[-10133715.460120313,1580106.248428125],[-10136161.445025,1580106.248428125]]]]},"properties":{"x":4048,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1582552.2333328128],[-10136161.445025,1584998.2182375006],[-10133715.460120313,1584998.2182375006],[-10133715.460120313,1582552.2333328128],[-10136161.445025,1582552.2333328128]]]]},"properties":{"x":4048,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1584998.2182375006],[-10136161.445025,1587444.2031421885],[-10133715.460120313,1587444.2031421885],[-10133715.460120313,1584998.2182375006],[-10136161.445025,1584998.2182375006]]]]},"properties":{"x":4048,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1587444.2031421885],[-10136161.445025,1589890.1880468763],[-10133715.460120313,1589890.1880468763],[-10133715.460120313,1587444.2031421885],[-10136161.445025,1587444.2031421885]]]]},"properties":{"x":4048,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1589890.1880468763],[-10136161.445025,1592336.1729515642],[-10133715.460120313,1592336.1729515642],[-10133715.460120313,1589890.1880468763],[-10136161.445025,1589890.1880468763]]]]},"properties":{"x":4048,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1592336.1729515642],[-10136161.445025,1594782.1578562483],[-10133715.460120313,1594782.1578562483],[-10133715.460120313,1592336.1729515642],[-10136161.445025,1592336.1729515642]]]]},"properties":{"x":4048,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1594782.1578562483],[-10136161.445025,1597228.1427609362],[-10133715.460120313,1597228.1427609362],[-10133715.460120313,1594782.1578562483],[-10136161.445025,1594782.1578562483]]]]},"properties":{"x":4048,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1597228.1427609362],[-10136161.445025,1599674.127665624],[-10133715.460120313,1599674.127665624],[-10133715.460120313,1597228.1427609362],[-10136161.445025,1597228.1427609362]]]]},"properties":{"x":4048,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1599674.127665624],[-10136161.445025,1602120.1125703119],[-10133715.460120313,1602120.1125703119],[-10133715.460120313,1599674.127665624],[-10136161.445025,1599674.127665624]]]]},"properties":{"x":4048,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1602120.1125703119],[-10136161.445025,1604566.0974749997],[-10133715.460120313,1604566.0974749997],[-10133715.460120313,1602120.1125703119],[-10136161.445025,1602120.1125703119]]]]},"properties":{"x":4048,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1604566.0974749997],[-10136161.445025,1607012.0823796876],[-10133715.460120313,1607012.0823796876],[-10133715.460120313,1604566.0974749997],[-10136161.445025,1604566.0974749997]]]]},"properties":{"x":4048,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1607012.0823796876],[-10136161.445025,1609458.0672843754],[-10133715.460120313,1609458.0672843754],[-10133715.460120313,1607012.0823796876],[-10136161.445025,1607012.0823796876]]]]},"properties":{"x":4048,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1609458.0672843754],[-10136161.445025,1611904.0521890633],[-10133715.460120313,1611904.0521890633],[-10133715.460120313,1609458.0672843754],[-10136161.445025,1609458.0672843754]]]]},"properties":{"x":4048,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1611904.0521890633],[-10136161.445025,1614350.0370937511],[-10133715.460120313,1614350.0370937511],[-10133715.460120313,1611904.0521890633],[-10136161.445025,1611904.0521890633]]]]},"properties":{"x":4048,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1614350.0370937511],[-10136161.445025,1616796.021998439],[-10133715.460120313,1616796.021998439],[-10133715.460120313,1614350.0370937511],[-10136161.445025,1614350.0370937511]]]]},"properties":{"x":4048,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1616796.021998439],[-10136161.445025,1619242.0069031268],[-10133715.460120313,1619242.0069031268],[-10133715.460120313,1616796.021998439],[-10136161.445025,1616796.021998439]]]]},"properties":{"x":4048,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1619242.0069031268],[-10136161.445025,1621687.991807811],[-10133715.460120313,1621687.991807811],[-10133715.460120313,1619242.0069031268],[-10136161.445025,1619242.0069031268]]]]},"properties":{"x":4048,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1621687.991807811],[-10136161.445025,1624133.9767124988],[-10133715.460120313,1624133.9767124988],[-10133715.460120313,1621687.991807811],[-10136161.445025,1621687.991807811]]]]},"properties":{"x":4048,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10136161.445025,1624133.9767124988],[-10136161.445025,1626579.9616171867],[-10133715.460120313,1626579.9616171867],[-10133715.460120313,1624133.9767124988],[-10136161.445025,1624133.9767124988]]]]},"properties":{"x":4048,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1562984.3540953137],[-10133715.460120313,1565430.3390000015],[-10131269.475215625,1565430.3390000015],[-10131269.475215625,1562984.3540953137],[-10133715.460120313,1562984.3540953137]]]]},"properties":{"x":4049,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1565430.3390000015],[-10133715.460120313,1567876.3239046894],[-10131269.475215625,1567876.3239046894],[-10131269.475215625,1565430.3390000015],[-10133715.460120313,1565430.3390000015]]]]},"properties":{"x":4049,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1567876.3239046894],[-10133715.460120313,1570322.3088093735],[-10131269.475215625,1570322.3088093735],[-10131269.475215625,1567876.3239046894],[-10133715.460120313,1567876.3239046894]]]]},"properties":{"x":4049,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1570322.3088093735],[-10133715.460120313,1572768.2937140614],[-10131269.475215625,1572768.2937140614],[-10131269.475215625,1570322.3088093735],[-10133715.460120313,1570322.3088093735]]]]},"properties":{"x":4049,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1572768.2937140614],[-10133715.460120313,1575214.2786187492],[-10131269.475215625,1575214.2786187492],[-10131269.475215625,1572768.2937140614],[-10133715.460120313,1572768.2937140614]]]]},"properties":{"x":4049,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1575214.2786187492],[-10133715.460120313,1577660.263523437],[-10131269.475215625,1577660.263523437],[-10131269.475215625,1575214.2786187492],[-10133715.460120313,1575214.2786187492]]]]},"properties":{"x":4049,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1577660.263523437],[-10133715.460120313,1580106.248428125],[-10131269.475215625,1580106.248428125],[-10131269.475215625,1577660.263523437],[-10133715.460120313,1577660.263523437]]]]},"properties":{"x":4049,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1580106.248428125],[-10133715.460120313,1582552.2333328128],[-10131269.475215625,1582552.2333328128],[-10131269.475215625,1580106.248428125],[-10133715.460120313,1580106.248428125]]]]},"properties":{"x":4049,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1582552.2333328128],[-10133715.460120313,1584998.2182375006],[-10131269.475215625,1584998.2182375006],[-10131269.475215625,1582552.2333328128],[-10133715.460120313,1582552.2333328128]]]]},"properties":{"x":4049,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1584998.2182375006],[-10133715.460120313,1587444.2031421885],[-10131269.475215625,1587444.2031421885],[-10131269.475215625,1584998.2182375006],[-10133715.460120313,1584998.2182375006]]]]},"properties":{"x":4049,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1587444.2031421885],[-10133715.460120313,1589890.1880468763],[-10131269.475215625,1589890.1880468763],[-10131269.475215625,1587444.2031421885],[-10133715.460120313,1587444.2031421885]]]]},"properties":{"x":4049,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1589890.1880468763],[-10133715.460120313,1592336.1729515642],[-10131269.475215625,1592336.1729515642],[-10131269.475215625,1589890.1880468763],[-10133715.460120313,1589890.1880468763]]]]},"properties":{"x":4049,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1592336.1729515642],[-10133715.460120313,1594782.1578562483],[-10131269.475215625,1594782.1578562483],[-10131269.475215625,1592336.1729515642],[-10133715.460120313,1592336.1729515642]]]]},"properties":{"x":4049,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1594782.1578562483],[-10133715.460120313,1597228.1427609362],[-10131269.475215625,1597228.1427609362],[-10131269.475215625,1594782.1578562483],[-10133715.460120313,1594782.1578562483]]]]},"properties":{"x":4049,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1597228.1427609362],[-10133715.460120313,1599674.127665624],[-10131269.475215625,1599674.127665624],[-10131269.475215625,1597228.1427609362],[-10133715.460120313,1597228.1427609362]]]]},"properties":{"x":4049,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1599674.127665624],[-10133715.460120313,1602120.1125703119],[-10131269.475215625,1602120.1125703119],[-10131269.475215625,1599674.127665624],[-10133715.460120313,1599674.127665624]]]]},"properties":{"x":4049,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1602120.1125703119],[-10133715.460120313,1604566.0974749997],[-10131269.475215625,1604566.0974749997],[-10131269.475215625,1602120.1125703119],[-10133715.460120313,1602120.1125703119]]]]},"properties":{"x":4049,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1604566.0974749997],[-10133715.460120313,1607012.0823796876],[-10131269.475215625,1607012.0823796876],[-10131269.475215625,1604566.0974749997],[-10133715.460120313,1604566.0974749997]]]]},"properties":{"x":4049,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1607012.0823796876],[-10133715.460120313,1609458.0672843754],[-10131269.475215625,1609458.0672843754],[-10131269.475215625,1607012.0823796876],[-10133715.460120313,1607012.0823796876]]]]},"properties":{"x":4049,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1609458.0672843754],[-10133715.460120313,1611904.0521890633],[-10131269.475215625,1611904.0521890633],[-10131269.475215625,1609458.0672843754],[-10133715.460120313,1609458.0672843754]]]]},"properties":{"x":4049,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1611904.0521890633],[-10133715.460120313,1614350.0370937511],[-10131269.475215625,1614350.0370937511],[-10131269.475215625,1611904.0521890633],[-10133715.460120313,1611904.0521890633]]]]},"properties":{"x":4049,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1614350.0370937511],[-10133715.460120313,1616796.021998439],[-10131269.475215625,1616796.021998439],[-10131269.475215625,1614350.0370937511],[-10133715.460120313,1614350.0370937511]]]]},"properties":{"x":4049,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1616796.021998439],[-10133715.460120313,1619242.0069031268],[-10131269.475215625,1619242.0069031268],[-10131269.475215625,1616796.021998439],[-10133715.460120313,1616796.021998439]]]]},"properties":{"x":4049,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1619242.0069031268],[-10133715.460120313,1621687.991807811],[-10131269.475215625,1621687.991807811],[-10131269.475215625,1619242.0069031268],[-10133715.460120313,1619242.0069031268]]]]},"properties":{"x":4049,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1621687.991807811],[-10133715.460120313,1624133.9767124988],[-10131269.475215625,1624133.9767124988],[-10131269.475215625,1621687.991807811],[-10133715.460120313,1621687.991807811]]]]},"properties":{"x":4049,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10133715.460120313,1624133.9767124988],[-10133715.460120313,1626579.9616171867],[-10131269.475215625,1626579.9616171867],[-10131269.475215625,1624133.9767124988],[-10133715.460120313,1624133.9767124988]]]]},"properties":{"x":4049,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1562984.3540953137],[-10131269.475215625,1565430.3390000015],[-10128823.490310939,1565430.3390000015],[-10128823.490310939,1562984.3540953137],[-10131269.475215625,1562984.3540953137]]]]},"properties":{"x":4050,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1565430.3390000015],[-10131269.475215625,1567876.3239046894],[-10128823.490310939,1567876.3239046894],[-10128823.490310939,1565430.3390000015],[-10131269.475215625,1565430.3390000015]]]]},"properties":{"x":4050,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1567876.3239046894],[-10131269.475215625,1570322.3088093735],[-10128823.490310939,1570322.3088093735],[-10128823.490310939,1567876.3239046894],[-10131269.475215625,1567876.3239046894]]]]},"properties":{"x":4050,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1570322.3088093735],[-10131269.475215625,1572768.2937140614],[-10128823.490310939,1572768.2937140614],[-10128823.490310939,1570322.3088093735],[-10131269.475215625,1570322.3088093735]]]]},"properties":{"x":4050,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1572768.2937140614],[-10131269.475215625,1575214.2786187492],[-10128823.490310939,1575214.2786187492],[-10128823.490310939,1572768.2937140614],[-10131269.475215625,1572768.2937140614]]]]},"properties":{"x":4050,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1575214.2786187492],[-10131269.475215625,1577660.263523437],[-10128823.490310939,1577660.263523437],[-10128823.490310939,1575214.2786187492],[-10131269.475215625,1575214.2786187492]]]]},"properties":{"x":4050,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1577660.263523437],[-10131269.475215625,1580106.248428125],[-10128823.490310939,1580106.248428125],[-10128823.490310939,1577660.263523437],[-10131269.475215625,1577660.263523437]]]]},"properties":{"x":4050,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1580106.248428125],[-10131269.475215625,1582552.2333328128],[-10128823.490310939,1582552.2333328128],[-10128823.490310939,1580106.248428125],[-10131269.475215625,1580106.248428125]]]]},"properties":{"x":4050,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1582552.2333328128],[-10131269.475215625,1584998.2182375006],[-10128823.490310939,1584998.2182375006],[-10128823.490310939,1582552.2333328128],[-10131269.475215625,1582552.2333328128]]]]},"properties":{"x":4050,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1584998.2182375006],[-10131269.475215625,1587444.2031421885],[-10128823.490310939,1587444.2031421885],[-10128823.490310939,1584998.2182375006],[-10131269.475215625,1584998.2182375006]]]]},"properties":{"x":4050,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1587444.2031421885],[-10131269.475215625,1589890.1880468763],[-10128823.490310939,1589890.1880468763],[-10128823.490310939,1587444.2031421885],[-10131269.475215625,1587444.2031421885]]]]},"properties":{"x":4050,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1589890.1880468763],[-10131269.475215625,1592336.1729515642],[-10128823.490310939,1592336.1729515642],[-10128823.490310939,1589890.1880468763],[-10131269.475215625,1589890.1880468763]]]]},"properties":{"x":4050,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1592336.1729515642],[-10131269.475215625,1594782.1578562483],[-10128823.490310939,1594782.1578562483],[-10128823.490310939,1592336.1729515642],[-10131269.475215625,1592336.1729515642]]]]},"properties":{"x":4050,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1594782.1578562483],[-10131269.475215625,1597228.1427609362],[-10128823.490310939,1597228.1427609362],[-10128823.490310939,1594782.1578562483],[-10131269.475215625,1594782.1578562483]]]]},"properties":{"x":4050,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1597228.1427609362],[-10131269.475215625,1599674.127665624],[-10128823.490310939,1599674.127665624],[-10128823.490310939,1597228.1427609362],[-10131269.475215625,1597228.1427609362]]]]},"properties":{"x":4050,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1599674.127665624],[-10131269.475215625,1602120.1125703119],[-10128823.490310939,1602120.1125703119],[-10128823.490310939,1599674.127665624],[-10131269.475215625,1599674.127665624]]]]},"properties":{"x":4050,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1602120.1125703119],[-10131269.475215625,1604566.0974749997],[-10128823.490310939,1604566.0974749997],[-10128823.490310939,1602120.1125703119],[-10131269.475215625,1602120.1125703119]]]]},"properties":{"x":4050,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1604566.0974749997],[-10131269.475215625,1607012.0823796876],[-10128823.490310939,1607012.0823796876],[-10128823.490310939,1604566.0974749997],[-10131269.475215625,1604566.0974749997]]]]},"properties":{"x":4050,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1607012.0823796876],[-10131269.475215625,1609458.0672843754],[-10128823.490310939,1609458.0672843754],[-10128823.490310939,1607012.0823796876],[-10131269.475215625,1607012.0823796876]]]]},"properties":{"x":4050,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1609458.0672843754],[-10131269.475215625,1611904.0521890633],[-10128823.490310939,1611904.0521890633],[-10128823.490310939,1609458.0672843754],[-10131269.475215625,1609458.0672843754]]]]},"properties":{"x":4050,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1611904.0521890633],[-10131269.475215625,1614350.0370937511],[-10128823.490310939,1614350.0370937511],[-10128823.490310939,1611904.0521890633],[-10131269.475215625,1611904.0521890633]]]]},"properties":{"x":4050,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1614350.0370937511],[-10131269.475215625,1616796.021998439],[-10128823.490310939,1616796.021998439],[-10128823.490310939,1614350.0370937511],[-10131269.475215625,1614350.0370937511]]]]},"properties":{"x":4050,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1616796.021998439],[-10131269.475215625,1619242.0069031268],[-10128823.490310939,1619242.0069031268],[-10128823.490310939,1616796.021998439],[-10131269.475215625,1616796.021998439]]]]},"properties":{"x":4050,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1619242.0069031268],[-10131269.475215625,1621687.991807811],[-10128823.490310939,1621687.991807811],[-10128823.490310939,1619242.0069031268],[-10131269.475215625,1619242.0069031268]]]]},"properties":{"x":4050,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1621687.991807811],[-10131269.475215625,1624133.9767124988],[-10128823.490310939,1624133.9767124988],[-10128823.490310939,1621687.991807811],[-10131269.475215625,1621687.991807811]]]]},"properties":{"x":4050,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10131269.475215625,1624133.9767124988],[-10131269.475215625,1626579.9616171867],[-10128823.490310939,1626579.9616171867],[-10128823.490310939,1624133.9767124988],[-10131269.475215625,1624133.9767124988]]]]},"properties":{"x":4050,"y":8856,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1562984.3540953137],[-10128823.490310939,1565430.3390000015],[-10126377.505406251,1565430.3390000015],[-10126377.505406251,1562984.3540953137],[-10128823.490310939,1562984.3540953137]]]]},"properties":{"x":4051,"y":8831,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1565430.3390000015],[-10128823.490310939,1567876.3239046894],[-10126377.505406251,1567876.3239046894],[-10126377.505406251,1565430.3390000015],[-10128823.490310939,1565430.3390000015]]]]},"properties":{"x":4051,"y":8832,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1567876.3239046894],[-10128823.490310939,1570322.3088093735],[-10126377.505406251,1570322.3088093735],[-10126377.505406251,1567876.3239046894],[-10128823.490310939,1567876.3239046894]]]]},"properties":{"x":4051,"y":8833,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1570322.3088093735],[-10128823.490310939,1572768.2937140614],[-10126377.505406251,1572768.2937140614],[-10126377.505406251,1570322.3088093735],[-10128823.490310939,1570322.3088093735]]]]},"properties":{"x":4051,"y":8834,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1572768.2937140614],[-10128823.490310939,1575214.2786187492],[-10126377.505406251,1575214.2786187492],[-10126377.505406251,1572768.2937140614],[-10128823.490310939,1572768.2937140614]]]]},"properties":{"x":4051,"y":8835,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1575214.2786187492],[-10128823.490310939,1577660.263523437],[-10126377.505406251,1577660.263523437],[-10126377.505406251,1575214.2786187492],[-10128823.490310939,1575214.2786187492]]]]},"properties":{"x":4051,"y":8836,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1577660.263523437],[-10128823.490310939,1580106.248428125],[-10126377.505406251,1580106.248428125],[-10126377.505406251,1577660.263523437],[-10128823.490310939,1577660.263523437]]]]},"properties":{"x":4051,"y":8837,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1580106.248428125],[-10128823.490310939,1582552.2333328128],[-10126377.505406251,1582552.2333328128],[-10126377.505406251,1580106.248428125],[-10128823.490310939,1580106.248428125]]]]},"properties":{"x":4051,"y":8838,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1582552.2333328128],[-10128823.490310939,1584998.2182375006],[-10126377.505406251,1584998.2182375006],[-10126377.505406251,1582552.2333328128],[-10128823.490310939,1582552.2333328128]]]]},"properties":{"x":4051,"y":8839,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1584998.2182375006],[-10128823.490310939,1587444.2031421885],[-10126377.505406251,1587444.2031421885],[-10126377.505406251,1584998.2182375006],[-10128823.490310939,1584998.2182375006]]]]},"properties":{"x":4051,"y":8840,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1587444.2031421885],[-10128823.490310939,1589890.1880468763],[-10126377.505406251,1589890.1880468763],[-10126377.505406251,1587444.2031421885],[-10128823.490310939,1587444.2031421885]]]]},"properties":{"x":4051,"y":8841,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1589890.1880468763],[-10128823.490310939,1592336.1729515642],[-10126377.505406251,1592336.1729515642],[-10126377.505406251,1589890.1880468763],[-10128823.490310939,1589890.1880468763]]]]},"properties":{"x":4051,"y":8842,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1592336.1729515642],[-10128823.490310939,1594782.1578562483],[-10126377.505406251,1594782.1578562483],[-10126377.505406251,1592336.1729515642],[-10128823.490310939,1592336.1729515642]]]]},"properties":{"x":4051,"y":8843,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1594782.1578562483],[-10128823.490310939,1597228.1427609362],[-10126377.505406251,1597228.1427609362],[-10126377.505406251,1594782.1578562483],[-10128823.490310939,1594782.1578562483]]]]},"properties":{"x":4051,"y":8844,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1597228.1427609362],[-10128823.490310939,1599674.127665624],[-10126377.505406251,1599674.127665624],[-10126377.505406251,1597228.1427609362],[-10128823.490310939,1597228.1427609362]]]]},"properties":{"x":4051,"y":8845,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1599674.127665624],[-10128823.490310939,1602120.1125703119],[-10126377.505406251,1602120.1125703119],[-10126377.505406251,1599674.127665624],[-10128823.490310939,1599674.127665624]]]]},"properties":{"x":4051,"y":8846,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1602120.1125703119],[-10128823.490310939,1604566.0974749997],[-10126377.505406251,1604566.0974749997],[-10126377.505406251,1602120.1125703119],[-10128823.490310939,1602120.1125703119]]]]},"properties":{"x":4051,"y":8847,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1604566.0974749997],[-10128823.490310939,1607012.0823796876],[-10126377.505406251,1607012.0823796876],[-10126377.505406251,1604566.0974749997],[-10128823.490310939,1604566.0974749997]]]]},"properties":{"x":4051,"y":8848,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1607012.0823796876],[-10128823.490310939,1609458.0672843754],[-10126377.505406251,1609458.0672843754],[-10126377.505406251,1607012.0823796876],[-10128823.490310939,1607012.0823796876]]]]},"properties":{"x":4051,"y":8849,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1609458.0672843754],[-10128823.490310939,1611904.0521890633],[-10126377.505406251,1611904.0521890633],[-10126377.505406251,1609458.0672843754],[-10128823.490310939,1609458.0672843754]]]]},"properties":{"x":4051,"y":8850,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1611904.0521890633],[-10128823.490310939,1614350.0370937511],[-10126377.505406251,1614350.0370937511],[-10126377.505406251,1611904.0521890633],[-10128823.490310939,1611904.0521890633]]]]},"properties":{"x":4051,"y":8851,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1614350.0370937511],[-10128823.490310939,1616796.021998439],[-10126377.505406251,1616796.021998439],[-10126377.505406251,1614350.0370937511],[-10128823.490310939,1614350.0370937511]]]]},"properties":{"x":4051,"y":8852,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1616796.021998439],[-10128823.490310939,1619242.0069031268],[-10126377.505406251,1619242.0069031268],[-10126377.505406251,1616796.021998439],[-10128823.490310939,1616796.021998439]]]]},"properties":{"x":4051,"y":8853,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1619242.0069031268],[-10128823.490310939,1621687.991807811],[-10126377.505406251,1621687.991807811],[-10126377.505406251,1619242.0069031268],[-10128823.490310939,1619242.0069031268]]]]},"properties":{"x":4051,"y":8854,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1621687.991807811],[-10128823.490310939,1624133.9767124988],[-10126377.505406251,1624133.9767124988],[-10126377.505406251,1621687.991807811],[-10128823.490310939,1621687.991807811]]]]},"properties":{"x":4051,"y":8855,"zoom":14,"isSquare":true}},{"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-10128823.490310939,1624133.9767124988],[-10128823.490310939,1626579.9616171867],[-10126377.505406251,1626579.9616171867],[-10126377.505406251,1624133.9767124988],[-10128823.490310939,1624133.9767124988]]]]},"properties":{"x":4051,"y":8856,"zoom":14,"isSquare":true}}]}} diff --git a/tests/api/helpers/test_files/canned_draft_project.json b/tests/api/helpers/test_files/canned_draft_project.json new file mode 100644 index 0000000000..01dce9f5ae --- /dev/null +++ b/tests/api/helpers/test_files/canned_draft_project.json @@ -0,0 +1,85 @@ +{ + "areaOfInterest": { + "type": "FeatureCollection", + "features": [ + { + "id": "de744f407ca8d92a7298ad2a13d362f5", + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [ + 84.767265, + 27.808938 + ], + [ + 84.774481, + 27.808117 + ], + [ + 84.774327, + 27.803786 + ], + [ + 84.765719, + 27.805017 + ], + [ + 84.767265, + 27.808938 + ] + ] + ], + "type": "Polygon" + } + } + ] + }, + "projectName": "Test", + "organisation": 23, + "tasks": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "x": 753, + "y": 594, + "zoom": 10, + "isSquare": false + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 84.767265, + 27.808938 + ], + [ + 84.774481, + 27.808117 + ], + [ + 84.774327, + 27.803786 + ], + [ + 84.765719, + 27.805017 + ], + [ + 84.767265, + 27.808938 + ] + ] + ] + ] + } + } + ] + }, + "arbitraryTasks": false +} diff --git a/tests/api/helpers/test_files/canned_kml_project.json b/tests/api/helpers/test_files/canned_kml_project.json new file mode 100644 index 0000000000..79d4c43455 --- /dev/null +++ b/tests/api/helpers/test_files/canned_kml_project.json @@ -0,0 +1,56 @@ +{ + "areaOfInterest": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -4.65996216497632, + 55.95041925350509, + 0 + ], + [ + -4.95173413390339, + 56.262794514545504, + 0 + ], + [ + -4.67367146099783, + 56.2434516969104, + 0 + ], + [ + -4.66709656284914, + 56.2435936767028, + 0 + ], + [ + -4.0046243330544, + 56.03981437664169, + 0 + ], + [ + -4.35742797997559, + 55.48727395069889, + 0 + ], + [ + -4.65996216497632, + 55.95041925350509, + 0 + ] + ] + ] + }, + "properties": null + } + ] + }, + "projectName": "kml", + "tasks": null, + "arbitraryTasks": true +} diff --git a/tests/api/helpers/test_files/canned_project_detail.json b/tests/api/helpers/test_files/canned_project_detail.json new file mode 100644 index 0000000000..3affb09ed3 --- /dev/null +++ b/tests/api/helpers/test_files/canned_project_detail.json @@ -0,0 +1,180 @@ +{ + "projectId": 8653, + "status": "PUBLISHED", + "projectPriority": "URGENT", + "areaOfInterest": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 83.771175, + 28.095768 + ], + [ + 83.773586, + 28.095757 + ], + [ + 83.773428, + 28.094146 + ], + [ + 83.770834, + 28.094479 + ], + [ + 83.771175, + 28.095768 + ] + ] + ] + ] + }, + "aoiBBOX": [ + 83.770834, + 28.094146, + 83.773586, + 28.095768 + ], + "tasks": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 83.771175, + 28.095768 + ], + [ + 83.773586, + 28.095757 + ], + [ + 83.773428, + 28.094146 + ], + [ + 83.770834, + 28.094479 + ], + [ + 83.771175, + 28.095768 + ] + ] + ] + ] + }, + "properties": { + "taskId": 1, + "taskX": 48018, + "taskY": 38100, + "taskZoom": 16, + "taskIsSquare": false, + "taskStatus": "READY", + "lockedBy": null + } + } + ] + }, + "defaultLocale": "en", + "projectInfo": { + "locale": "en", + "name": "Test", + "shortDescription": "", + "description": "", + "instructions": "", + "perTaskInstructions": "" + }, + "projectInfoLocales": [ + { + "locale": "en", + "name": "Test", + "shortDescription": "Test short description", + "description": "Test description", + "instructions": "Testing detailed instructions", + "perTaskInstructions": "Tests" + } + ], + "difficulty": "EASY", + "mappingPermission": "ANY", + "validationPermission": "LEVEL", + "enforceRandomTaskSelection": false, + "private": false, + "changesetComment": "#hot-tm-stage-project-8653", + "osmchaFilterId": null, + "dueDate": null, + "imagery": null, + "idPresets": null, + "rapidPowerUser": false, + "mappingTypes": [ + "ROADS", + "BUILDINGS" + ], + "campaigns": [], + "organisation": 23, + "organisationName": "Kathmandu Living Labs", + "organisationSlug": "KLL", + "organisationLogo": "https://cdn.hotosm.org/tasking-manager/uploads/1652896455106_main-logo.png", + "countryTag": [ + "Nepal" + ], + "licenseId": null, + "allowedUsernames": [], + "priorityAreas": [ + { + "coordinates": [ + [ + [ + 83.77175831878799, + 28.095285381948983 + ], + [ + 83.77256705935366, + 28.095301973647963 + ], + [ + 83.77252317420658, + 28.094837405100535 + ], + [ + 83.77168935641481, + 28.094876119222604 + ], + [ + 83.77175831878799, + 28.095285381948983 + ] + ] + ], + "type": "Polygon" + } + ], + "created": "2022-05-19T04:59:47.310401Z", + "lastUpdated": "2022-05-19T04:59:47.305580Z", + "author": "Thinkwhere Test", + "activeMappers": 0, + "percentMapped": 0, + "percentValidated": 0, + "percentBadImagery": 0, + "taskCreationMode": "GRID", + "teams": [], + "mappingEditors": [ + "ID", + "JOSM", + "CUSTOM" + ], + "validationEditors": [ + "ID", + "JOSM", + "CUSTOM" + ], + "interests": [], + "extraIdParams": "" +} diff --git a/tests/api/helpers/test_files/clipped_feature_collection.json b/tests/api/helpers/test_files/clipped_feature_collection.json new file mode 100644 index 0000000000..c81c2f4eb2 --- /dev/null +++ b/tests/api/helpers/test_files/clipped_feature_collection.json @@ -0,0 +1,1145 @@ +{ + "type":"FeatureCollection", + "features":[ + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275034.3899838, + 2828857.9792994 + ], + [ + 1275046.0685966, + 2828857.9792994 + ], + [ + 1275046.0685966, + 2828781.5422711 + ], + [ + 1275016.6455452, + 2828781.5422711 + ], + [ + 1275034.3899838, + 2828857.9792994 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278824, + "y":299152, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275046.0685966, + 2828908.286806 + ], + [ + 1275046.0685966, + 2828857.9792994 + ], + [ + 1275034.3899838, + 2828857.9792994 + ], + [ + 1275046.0685966, + 2828908.286806 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278824, + "y":299153, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275046.0685966, + 2828781.5422711 + ], + [ + 1275046.0685966, + 2828857.9792994 + ], + [ + 1275122.5056249, + 2828857.9792994 + ], + [ + 1275122.5056249, + 2828781.5422711 + ], + [ + 1275046.0685966, + 2828781.5422711 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278825, + "y":299152, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275052.1344223, + 2828934.4163276 + ], + [ + 1275122.5056249, + 2828934.4163276 + ], + [ + 1275122.5056249, + 2828857.9792994 + ], + [ + 1275046.0685966, + 2828857.9792994 + ], + [ + 1275046.0685966, + 2828908.286806 + ], + [ + 1275052.1344223, + 2828934.4163276 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278825, + "y":299153, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275069.8788608, + 2829010.8533559 + ], + [ + 1275122.5056249, + 2829010.8533559 + ], + [ + 1275122.5056249, + 2828934.4163276 + ], + [ + 1275052.1344223, + 2828934.4163276 + ], + [ + 1275069.8788608, + 2829010.8533559 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278825, + "y":299154, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275087.6232994, + 2829087.2903842 + ], + [ + 1275122.5056249, + 2829087.2903842 + ], + [ + 1275122.5056249, + 2829010.8533559 + ], + [ + 1275069.8788608, + 2829010.8533559 + ], + [ + 1275087.6232994, + 2829087.2903842 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278825, + "y":299155, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275122.5056249, + 2828781.5422711 + ], + [ + 1275122.5056249, + 2828857.9792994 + ], + [ + 1275198.9426532, + 2828857.9792994 + ], + [ + 1275198.9426532, + 2828781.5422711 + ], + [ + 1275122.5056249, + 2828781.5422711 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278826, + "y":299152, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275122.5056249, + 2828857.9792994 + ], + [ + 1275122.5056249, + 2828934.4163276 + ], + [ + 1275198.9426532, + 2828934.4163276 + ], + [ + 1275198.9426532, + 2828857.9792994 + ], + [ + 1275122.5056249, + 2828857.9792994 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278826, + "y":299153, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275122.5056249, + 2828934.4163276 + ], + [ + 1275122.5056249, + 2829010.8533559 + ], + [ + 1275198.9426532, + 2829010.8533559 + ], + [ + 1275198.9426532, + 2828934.4163276 + ], + [ + 1275122.5056249, + 2828934.4163276 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278826, + "y":299154, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275122.5056249, + 2829010.8533559 + ], + [ + 1275122.5056249, + 2829087.2903842 + ], + [ + 1275198.9426532, + 2829087.2903842 + ], + [ + 1275198.9426532, + 2829010.8533559 + ], + [ + 1275122.5056249, + 2829010.8533559 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278826, + "y":299155, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275275.3796814, + 2828783.0982896 + ], + [ + 1275200.6907704, + 2828781.5422711 + ], + [ + 1275198.9426532, + 2828781.5422711 + ], + [ + 1275198.9426532, + 2828857.9792994 + ], + [ + 1275275.3796814, + 2828857.9792994 + ], + [ + 1275275.3796814, + 2828783.0982896 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278827, + "y":299152, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275198.9426532, + 2828857.9792994 + ], + [ + 1275198.9426532, + 2828934.4163276 + ], + [ + 1275275.3796814, + 2828934.4163276 + ], + [ + 1275275.3796814, + 2828857.9792994 + ], + [ + 1275198.9426532, + 2828857.9792994 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278827, + "y":299153, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275198.9426532, + 2828934.4163276 + ], + [ + 1275198.9426532, + 2829010.8533559 + ], + [ + 1275275.3796814, + 2829010.8533559 + ], + [ + 1275275.3796814, + 2828934.4163276 + ], + [ + 1275198.9426532, + 2828934.4163276 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278827, + "y":299154, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275198.9426532, + 2829010.8533559 + ], + [ + 1275198.9426532, + 2829087.2903842 + ], + [ + 1275275.3796814, + 2829087.2903842 + ], + [ + 1275275.3796814, + 2829010.8533559 + ], + [ + 1275198.9426532, + 2829010.8533559 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278827, + "y":299155, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275581.1277945, + 2829087.2903842 + ], + [ + 1275581.1277945, + 2829163.7274125 + ], + [ + 1275657.5648228, + 2829163.7274125 + ], + [ + 1275657.5648228, + 2829087.2903842 + ], + [ + 1275581.1277945, + 2829087.2903842 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278832, + "y":299156, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275581.1277945, + 2829163.7274125 + ], + [ + 1275581.1277945, + 2829240.1644407 + ], + [ + 1275657.5648228, + 2829240.1644407 + ], + [ + 1275657.5648228, + 2829163.7274125 + ], + [ + 1275581.1277945, + 2829163.7274125 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278832, + "y":299157, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275581.1277945, + 2829306.6212479 + ], + [ + 1275657.5648228, + 2829305.5596227 + ], + [ + 1275657.5648228, + 2829240.1644407 + ], + [ + 1275581.1277945, + 2829240.1644407 + ], + [ + 1275581.1277945, + 2829306.6212479 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278832, + "y":299158, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275657.5648228, + 2829087.2903842 + ], + [ + 1275657.5648228, + 2829163.7274125 + ], + [ + 1275734.0018511, + 2829163.7274125 + ], + [ + 1275734.0018511, + 2829087.2903842 + ], + [ + 1275657.5648228, + 2829087.2903842 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278833, + "y":299156, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275657.5648228, + 2829163.7274125 + ], + [ + 1275657.5648228, + 2829240.1644407 + ], + [ + 1275734.0018511, + 2829240.1644407 + ], + [ + 1275734.0018511, + 2829163.7274125 + ], + [ + 1275657.5648228, + 2829163.7274125 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278833, + "y":299157, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275734.0018511, + 2829304.4979975 + ], + [ + 1275734.0018511, + 2829240.1644407 + ], + [ + 1275657.5648228, + 2829240.1644407 + ], + [ + 1275657.5648228, + 2829305.5596227 + ], + [ + 1275734.0018511, + 2829304.4979975 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278833, + "y":299158, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275734.0018511, + 2829087.2903842 + ], + [ + 1275734.0018511, + 2829163.7274125 + ], + [ + 1275810.4388793, + 2829163.7274125 + ], + [ + 1275810.4388793, + 2829087.2903842 + ], + [ + 1275734.0018511, + 2829087.2903842 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278834, + "y":299156, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275734.0018511, + 2829163.7274125 + ], + [ + 1275734.0018511, + 2829240.1644407 + ], + [ + 1275810.4388793, + 2829240.1644407 + ], + [ + 1275810.4388793, + 2829163.7274125 + ], + [ + 1275734.0018511, + 2829163.7274125 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":true, + "x":278834, + "y":299157, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275810.4388793, + 2829303.4363723 + ], + [ + 1275810.4388793, + 2829240.1644407 + ], + [ + 1275734.0018511, + 2829240.1644407 + ], + [ + 1275734.0018511, + 2829304.4979975 + ], + [ + 1275810.4388793, + 2829303.4363723 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278834, + "y":299158, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275810.4388793, + 2829087.2903842 + ], + [ + 1275810.4388793, + 2829163.7274125 + ], + [ + 1275856.8401371, + 2829163.7274125 + ], + [ + 1275872.7042755, + 2829087.2903842 + ], + [ + 1275810.4388793, + 2829087.2903842 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278835, + "y":299156, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275856.8401371, + 2829163.7274125 + ], + [ + 1275810.4388793, + 2829163.7274125 + ], + [ + 1275810.4388793, + 2829240.1644407 + ], + [ + 1275840.9759988, + 2829240.1644407 + ], + [ + 1275856.8401371, + 2829163.7274125 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278835, + "y":299157, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275827.8945302, + 2829303.1939328 + ], + [ + 1275840.9759988, + 2829240.1644407 + ], + [ + 1275810.4388793, + 2829240.1644407 + ], + [ + 1275810.4388793, + 2829303.4363723 + ], + [ + 1275827.8945302, + 2829303.1939328 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278835, + "y":299158, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275886.8759076, + 2828795.8377961 + ], + [ + 1275886.8759076, + 2828857.9792994 + ], + [ + 1275920.2974494, + 2828857.9792994 + ], + [ + 1275932.995444, + 2828796.7986204 + ], + [ + 1275886.8759076, + 2828795.8377961 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278836, + "y":299152, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275886.8759076, + 2828934.4163276 + ], + [ + 1275904.4330133, + 2828934.4163276 + ], + [ + 1275920.2974494, + 2828857.9792994 + ], + [ + 1275886.8759076, + 2828857.9792994 + ], + [ + 1275886.8759076, + 2828934.4163276 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278836, + "y":299153, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275886.8759076, + 2829010.8533559 + ], + [ + 1275888.5685772, + 2829010.8533559 + ], + [ + 1275904.4330133, + 2828934.4163276 + ], + [ + 1275886.8759076, + 2828934.4163276 + ], + [ + 1275886.8759076, + 2829010.8533559 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278836, + "y":299154, + "zoom":19 + } + }, + { + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + 1275886.8759076, + 2829019.00887 + ], + [ + 1275888.5685772, + 2829010.8533559 + ], + [ + 1275886.8759076, + 2829010.8533559 + ], + [ + 1275886.8759076, + 2829019.00887 + ] + ] + ] + ] + }, + "properties":{ + "isSquare":false, + "x":278836, + "y":299155, + "zoom":19 + } + } + ] +} diff --git a/tests/api/helpers/test_files/feature_collection.json b/tests/api/helpers/test_files/feature_collection.json new file mode 100644 index 0000000000..79c474a2fc --- /dev/null +++ b/tests/api/helpers/test_files/feature_collection.json @@ -0,0 +1,1145 @@ +{ + "features": [ + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278824, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278824, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} diff --git a/tests/api/helpers/test_files/feature_collection_one_multipolygon.json b/tests/api/helpers/test_files/feature_collection_one_multipolygon.json new file mode 100644 index 0000000000..5f38017036 --- /dev/null +++ b/tests/api/helpers/test_files/feature_collection_one_multipolygon.json @@ -0,0 +1,1563 @@ +{ + "features": [ + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278824, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278824, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278825, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278826, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278827, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278831, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278831, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278831, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829316.601468995 + ], + [ + 1275581.1277945302, + 2829316.601468995 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278831, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278832, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278833, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278834, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299156, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299157, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278835, + "y": 299158, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828781.5422710925 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299152, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299153, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299154, + "zoom": 19 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 278836, + "y": 299155, + "zoom": 19 + }, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} diff --git a/tests/api/helpers/test_files/multi_polygon.json b/tests/api/helpers/test_files/multi_polygon.json new file mode 100644 index 0000000000..0806502ef9 --- /dev/null +++ b/tests/api/helpers/test_files/multi_polygon.json @@ -0,0 +1,77 @@ +{ + "coordinates": [ + [ + [ + [ + 1275275.379681445, + 2828783.098289569 + ], + [ + 1275200.690770448, + 2828781.542271093 + ], + [ + 1275016.64554516, + 2828781.542271093 + ], + [ + 1275087.623299365, + 2829087.290384181 + ], + [ + 1275275.379681445, + 2829087.290384181 + ], + [ + 1275275.379681445, + 2828783.098289569 + ] + ] + ], + [ + [ + [ + 1275581.12779453, + 2829306.621247852 + ], + [ + 1275827.894530152, + 2829303.193932765 + ], + [ + 1275872.704275511, + 2829087.290384181 + ], + [ + 1275581.12779453, + 2829087.290384181 + ], + [ + 1275581.12779453, + 2829306.621247852 + ] + ] + ], + [ + [ + [ + 1275886.875907619, + 2829019.008869979 + ], + [ + 1275932.995444044, + 2828796.798620377 + ], + [ + 1275886.875907619, + 2828795.837796132 + ], + [ + 1275886.875907619, + 2829019.008869979 + ] + ] + ] + ], + "type": "MultiPolygon" +} diff --git a/tests/api/helpers/test_files/multi_polygon_dissolved.json b/tests/api/helpers/test_files/multi_polygon_dissolved.json new file mode 100644 index 0000000000..08c86cf7fa --- /dev/null +++ b/tests/api/helpers/test_files/multi_polygon_dissolved.json @@ -0,0 +1,77 @@ +{ + "coordinates": [ + [ + [ + [ + 1275886.875907619, + 2829019.008869979 + ], + [ + 1275932.995444044, + 2828796.798620377 + ], + [ + 1275886.875907619, + 2828795.837796132 + ], + [ + 1275886.875907619, + 2829019.008869979 + ] + ] + ], + [ + [ + [ + 1275275.379681445, + 2828783.098289569 + ], + [ + 1275200.690770448, + 2828781.542271093 + ], + [ + 1275016.64554516, + 2828781.542271093 + ], + [ + 1275087.623299365, + 2829087.290384181 + ], + [ + 1275275.379681445, + 2829087.290384181 + ], + [ + 1275275.379681445, + 2828783.098289569 + ] + ] + ], + [ + [ + [ + 1275581.12779453, + 2829306.621247852 + ], + [ + 1275827.894530152, + 2829303.193932765 + ], + [ + 1275872.704275511, + 2829087.290384181 + ], + [ + 1275581.12779453, + 2829087.290384181 + ], + [ + 1275581.12779453, + 2829306.621247852 + ] + ] + ] + ], + "type": "MultiPolygon" +} diff --git a/tests/api/helpers/test_files/non_square_project_aoi.json b/tests/api/helpers/test_files/non_square_project_aoi.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/api/helpers/test_files/non_square_split_results.json b/tests/api/helpers/test_files/non_square_split_results.json new file mode 100644 index 0000000000..67ef17f98b --- /dev/null +++ b/tests/api/helpers/test_files/non_square_split_results.json @@ -0,0 +1,214 @@ +[ + { + "geometry": { + "coordinates": [ + [ + [ + [ + 152.401608, + -31.9521622 + ], + [ + 151.875, + -31.9521622 + ], + [ + 151.875, + -31.3112796 + ], + [ + 152.401608, + -31.3112796 + ], + [ + 152.401608, + -31.9521622 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": false, + "x": null, + "y": null, + "zoom": null + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 151.875, + -31.3112796 + ], + [ + 151.875, + -30.7512778 + ], + [ + 152.401608, + -30.7512778 + ], + [ + 152.401608, + -31.3112796 + ], + [ + 151.875, + -31.3112796 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": false, + "x": null, + "y": null, + "zoom": null + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 152.9646946, + -31.3112796 + ], + [ + 152.9646946, + -31.3148763 + ], + [ + 152.9409267, + -31.3605527 + ], + [ + 152.9495622, + -31.4453842 + ], + [ + 152.9052747, + -31.4771805 + ], + [ + 152.8577388, + -31.5734156 + ], + [ + 152.8042608, + -31.6695516 + ], + [ + 152.7329569, + -31.8160937 + ], + [ + 152.673537, + -31.8817097 + ], + [ + 152.5951471, + -31.9521622 + ], + [ + 152.401608, + -31.9521622 + ], + [ + 152.401608, + -31.3112796 + ], + [ + 152.9646946, + -31.3112796 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": false, + "x": null, + "y": null, + "zoom": null + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + 153.0026299, + -30.7512778 + ], + [ + 153.0122306, + -30.80077 + ], + [ + 153.0419406, + -30.8568964 + ], + [ + 153.0894765, + -30.91299 + ], + [ + 153.0478825, + -30.994522 + ], + [ + 153.0419406, + -31.0658054 + ], + [ + 153.0003466, + -31.1268629 + ], + [ + 152.9646946, + -31.223457 + ], + [ + 152.9646946, + -31.3112796 + ], + [ + 152.401608, + -31.3112796 + ], + [ + 152.401608, + -30.7512778 + ], + [ + 153.0026299, + -30.7512778 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": false, + "x": null, + "y": null, + "zoom": null + }, + "type": "Feature" + } +] diff --git a/tests/api/helpers/test_files/non_square_task.json b/tests/api/helpers/test_files/non_square_task.json new file mode 100644 index 0000000000..c88d7cd023 --- /dev/null +++ b/tests/api/helpers/test_files/non_square_task.json @@ -0,0 +1,38 @@ +{ + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [153.002629854192, -30.7512777712788], + [153.012230588, -30.800769981], + [153.041940554, -30.8568964], + [153.0894765, -30.912989989], + [153.047882548, -30.994521987], + [153.041940554, -31.065805365], + [153.000346602, -31.126862915], + [152.964694643, -31.223457045], + [152.964694643, -31.314876311], + [152.94092667, -31.360552708], + [152.949562214, -31.445384196], + [152.905274711, -31.47718046], + [152.857738766, -31.573415644], + [152.804260827, -31.669551593], + [152.732956909, -31.816093707], + [152.673536977, -31.881709671], + [152.595147099906, -31.9521622328954], + [151.874999972795, -31.9521622328954], + [151.874999972795, -30.7512777712788], + [153.002629854192, -30.7512777712788] + ] + ] + ] + }, + "properties": { + "x": 236, + "y": 104, + "zoom": 8, + "isSquare": false + } +} diff --git a/tests/api/helpers/test_files/osm_sample.xml b/tests/api/helpers/test_files/osm_sample.xml new file mode 100644 index 0000000000..e047ba76d7 --- /dev/null +++ b/tests/api/helpers/test_files/osm_sample.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/api/helpers/test_files/osm_user_details.json b/tests/api/helpers/test_files/osm_user_details.json new file mode 100644 index 0000000000..f8ab062926 --- /dev/null +++ b/tests/api/helpers/test_files/osm_user_details.json @@ -0,0 +1,47 @@ +{ + "version": "0.6", + "generator": "OpenStreetMap server", + "copyright": "OpenStreetMap and contributors", + "attribution": "http://www.openstreetmap.org/copyright", + "license": "http://opendatacommons.org/licenses/odbl/1-0/", + "user": { + "id": 7777777, + "display_name": "Thinkwhere Test", + "account_created": "2017-01-23T16:23:22Z", + "description": "", + "contributor_terms": { + "agreed": true, + "pd": false + }, + "roles": [], + "changesets": { + "count": 16 + }, + "traces": { + "count": 0 + }, + "blocks": { + "received": { + "count": 0, + "active": 0 + } + }, + "home": { + "lat": 39.1109, + "lon": -2.991, + "zoom": 3 + }, + "languages": [ + "Spa." + ], + "messages": { + "received": { + "count": 0, + "unread": 0 + }, + "sent": { + "count": 0 + } + } + } +} diff --git a/tests/api/helpers/test_files/osm_user_details_changed_name.xml b/tests/api/helpers/test_files/osm_user_details_changed_name.xml new file mode 100644 index 0000000000..f0a672d63a --- /dev/null +++ b/tests/api/helpers/test_files/osm_user_details_changed_name.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + en-GB + en + + + + + + + diff --git a/tests/api/helpers/test_files/osm_user_details_simple.xml b/tests/api/helpers/test_files/osm_user_details_simple.xml new file mode 100644 index 0000000000..1b55db0e92 --- /dev/null +++ b/tests/api/helpers/test_files/osm_user_details_simple.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/api/helpers/test_files/search_bbox_feature.json b/tests/api/helpers/test_files/search_bbox_feature.json new file mode 100644 index 0000000000..e7bab897c4 --- /dev/null +++ b/tests/api/helpers/test_files/search_bbox_feature.json @@ -0,0 +1,2917 @@ +{ + "coordinates": [ + [ + [ + [ + 34.0893707268651, + -0.536513984601753 + ], + [ + 34.0837097165999, + -0.535368025180619 + ], + [ + 34.0824012756666, + -0.522611022751177 + ], + [ + 34.0835189818563, + -0.521118998858031 + ], + [ + 34.0859489436038, + -0.520412028506349 + ], + [ + 34.0887794491696, + -0.522691011775293 + ], + [ + 34.0938186647061, + -0.53062498601422 + ], + [ + 34.0922393800162, + -0.534533023725973 + ], + [ + 34.0893707268651, + -0.536513984601753 + ] + ] + ], + [ + [ + [ + 34.0964317315364, + -0.473861009201641 + ], + [ + 34.0903701783458, + -0.477777987607268 + ], + [ + 34.0894088743771, + -0.480300993106367 + ], + [ + 34.0840606689686, + -0.483107000623554 + ], + [ + 34.0832290642053, + -0.485565990460461 + ], + [ + 34.0777206420479, + -0.488045990479494 + ], + [ + 34.0756301877947, + -0.48638200784417 + ], + [ + 34.0795097347646, + -0.483161986306938 + ], + [ + 34.0812988279751, + -0.477977007985046 + ], + [ + 34.0807914731244, + -0.474739998452093 + ], + [ + 34.0869102477411, + -0.474655986304899 + ], + [ + 34.0935516357163, + -0.469579995295603 + ], + [ + 34.0973091126488, + -0.472213000194603 + ], + [ + 34.0964317315364, + -0.473861009201641 + ] + ] + ], + [ + [ + [ + 33.9454116820645, + -0.463559001916262 + ], + [ + 33.9395599365248, + -0.463423997083882 + ], + [ + 33.9403190610894, + -0.459735006029653 + ], + [ + 33.945270538096, + -0.461468995234408 + ], + [ + 33.9454116820645, + -0.463559001916262 + ] + ] + ], + [ + [ + [ + 33.955379485646, + -0.439756006467532 + ], + [ + 33.9534111015895, + -0.441495001379958 + ], + [ + 33.9522895809163, + -0.436744988255081 + ], + [ + 33.9562606813082, + -0.436861009014392 + ], + [ + 33.955379485646, + -0.439756006467532 + ] + ] + ], + [ + [ + [ + 34.0426788324311, + -0.441617995922242 + ], + [ + 34.0579109193315, + -0.454786986694148 + ], + [ + 34.0618209834879, + -0.466004014204983 + ], + [ + 34.0680007929779, + -0.475248992840064 + ], + [ + 34.0617904656388, + -0.483705997355769 + ], + [ + 34.0581703181331, + -0.486158997364131 + ], + [ + 34.0514488214515, + -0.48744401352108 + ], + [ + 34.0425796502369, + -0.492495000572894 + ], + [ + 34.0173301691978, + -0.49810600351348 + ], + [ + 33.9986495967674, + -0.499177009522071 + ], + [ + 33.9948692319355, + -0.495505989061121 + ], + [ + 33.9925994866689, + -0.489533991119976 + ], + [ + 33.9869308467725, + -0.485076010336786 + ], + [ + 33.9746284482024, + -0.482282013259886 + ], + [ + 33.9698791503386, + -0.478166014668594 + ], + [ + 33.9621696468858, + -0.480320007289396 + ], + [ + 33.959869384821, + -0.4795329870708 + ], + [ + 33.9616203304768, + -0.476166993823649 + ], + [ + 33.9591789242271, + -0.474866003235944 + ], + [ + 33.9557609557607, + -0.476051002849935 + ], + [ + 33.9492187498746, + -0.472840011421986 + ], + [ + 33.9448890686212, + -0.473563998816022 + ], + [ + 33.9456787106334, + -0.471403986181399 + ], + [ + 33.9551696776208, + -0.467249006781304 + ], + [ + 33.9556808469152, + -0.463665991905308 + ], + [ + 33.9537200921702, + -0.46022900949028 + ], + [ + 33.9546508785988, + -0.458112001448788 + ], + [ + 33.9502983093133, + -0.45217698870175 + ], + [ + 33.9588890074224, + -0.451597989312783 + ], + [ + 33.9601211547147, + -0.446150988264086 + ], + [ + 33.9670181272535, + -0.441754013979948 + ], + [ + 33.9700202941679, + -0.442532002917132 + ], + [ + 33.9751205437283, + -0.451126009810991 + ], + [ + 33.9788589470794, + -0.451622992748461 + ], + [ + 33.9860610955408, + -0.448074013291747 + ], + [ + 33.993858337062, + -0.441381007317147 + ], + [ + 33.9966392511954, + -0.441868007649385 + ], + [ + 34.0144500725981, + -0.436924994268909 + ], + [ + 34.0217704769388, + -0.426748991155693 + ], + [ + 34.0259399414171, + -0.424603999414845 + ], + [ + 34.0310096737433, + -0.425832003288011 + ], + [ + 34.0374183653533, + -0.436044991164478 + ], + [ + 34.0426788324311, + -0.441617995922242 + ] + ] + ], + [ + [ + [ + 34.2754516596662, + -0.401969999488986 + ], + [ + 34.2715797423912, + -0.401030987644172 + ], + [ + 34.2744903559876, + -0.400332987688116 + ], + [ + 34.2754516596662, + -0.401969999488986 + ] + ] + ], + [ + [ + [ + 34.2781715389658, + -0.381237000492777 + ], + [ + 34.2761688228689, + -0.382865995608465 + ], + [ + 34.2743492121104, + -0.381680995640269 + ], + [ + 34.2783508301769, + -0.379925996760625 + ], + [ + 34.2781715389658, + -0.381237000492777 + ] + ] + ], + [ + [ + [ + 34.2143287655156, + -0.395693004366286 + ], + [ + 34.2142906182445, + -0.405660004031797 + ], + [ + 34.2120018004263, + -0.411262006264649 + ], + [ + 34.204349517321, + -0.415695012092491 + ], + [ + 34.2029495239652, + -0.419582993174731 + ], + [ + 34.1996002197026, + -0.421483993822473 + ], + [ + 34.1877708433517, + -0.421559006677778 + ], + [ + 34.1794395440855, + -0.427493990015186 + ], + [ + 34.1756782531266, + -0.432850003983613 + ], + [ + 34.165271758304, + -0.438524991595183 + ], + [ + 34.1613998410085, + -0.444068014742153 + ], + [ + 34.1456985469489, + -0.429895997800304 + ], + [ + 34.1378898619166, + -0.428847998925689 + ], + [ + 34.136909484728, + -0.432891011472217 + ], + [ + 34.134300231592, + -0.433950991107749 + ], + [ + 34.1333389278868, + -0.437920988165511 + ], + [ + 34.1311607357573, + -0.438971996391205 + ], + [ + 34.1283798215211, + -0.437976003020405 + ], + [ + 34.1264610287079, + -0.433889001655333 + ], + [ + 34.1176605223996, + -0.427177011840722 + ], + [ + 34.1166992184456, + -0.422545999545391 + ], + [ + 34.1192703248463, + -0.411130011306752 + ], + [ + 34.1313514703541, + -0.415950000813917 + ], + [ + 34.1384315486929, + -0.412773013288862 + ], + [ + 34.1467704774138, + -0.40178999330445 + ], + [ + 34.1569900507969, + -0.399167001650706 + ], + [ + 34.1601295471043, + -0.394279987164107 + ], + [ + 34.160659789502, + -0.3878209894747 + ], + [ + 34.1580696104563, + -0.384274989420933 + ], + [ + 34.1581687924587, + -0.37681299451016 + ], + [ + 34.1667213436273, + -0.378331006020431 + ], + [ + 34.1715507505398, + -0.380257010370037 + ], + [ + 34.1740303033703, + -0.384128988337088 + ], + [ + 34.1842308041676, + -0.388287991819939 + ], + [ + 34.1941604611658, + -0.39040300288116 + ], + [ + 34.1969604491079, + -0.398047000614702 + ], + [ + 34.1994781489183, + -0.400009989819131 + ], + [ + 34.2037086481541, + -0.397612005717622 + ], + [ + 34.2089195247285, + -0.390827000545428 + ], + [ + 34.2076797480607, + -0.387234986055222 + ], + [ + 34.2105598449187, + -0.376715004601617 + ], + [ + 34.2103385922229, + -0.36443001056801 + ], + [ + 34.2191810601522, + -0.36015901038489 + ], + [ + 34.2211189268839, + -0.354171991759936 + ], + [ + 34.2236709588762, + -0.356640011645463 + ], + [ + 34.2278518671235, + -0.357789009821622 + ], + [ + 34.2276496881491, + -0.359632999264451 + ], + [ + 34.2177581787943, + -0.380856007496425 + ], + [ + 34.2143287655156, + -0.395693004366286 + ] + ] + ], + [ + [ + [ + 34.2518005369727, + -0.355477988879186 + ], + [ + 34.2492980956664, + -0.356083989530268 + ], + [ + 34.2500114441154, + -0.352638006396314 + ], + [ + 34.2546806336564, + -0.351561993650388 + ], + [ + 34.2518005369727, + -0.355477988879186 + ] + ] + ], + [ + [ + [ + 34.2636184686575, + -0.346350014132461 + ], + [ + 34.2605094907419, + -0.351669013877824 + ], + [ + 34.2555198663605, + -0.3504230086018 + ], + [ + 34.2567787165965, + -0.34663200411094 + ], + [ + 34.2636184686575, + -0.346350014132461 + ] + ] + ], + [ + [ + [ + 34.7582206719739, + -0.31252101087114 + ], + [ + 34.7583808897327, + -0.31669801508201 + ], + [ + 34.7630691524777, + -0.315403998108347 + ], + [ + 34.7676086423743, + -0.314383000305777 + ], + [ + 34.7735099793049, + -0.320845991625677 + ], + [ + 34.7772483822775, + -0.320601999839968 + ], + [ + 34.7792587277671, + -0.322156995636362 + ], + [ + 34.7803993223333, + -0.325801014855357 + ], + [ + 34.7830009457264, + -0.324263006498863 + ], + [ + 34.7873992919402, + -0.332888990771244 + ], + [ + 34.7825813293905, + -0.334661990704691 + ], + [ + 34.7857704159269, + -0.346581995440046 + ], + [ + 34.7891006467147, + -0.345259994365036 + ], + [ + 34.7910690307328, + -0.345991999112342 + ], + [ + 34.7900810241752, + -0.348499000422978 + ], + [ + 34.7909393311158, + -0.350154012735152 + ], + [ + 34.7962989806356, + -0.351779014158352 + ], + [ + 34.7963409423905, + -0.353913993008774 + ], + [ + 34.799270629772, + -0.35537099866552 + ], + [ + 34.8058509826727, + -0.356851994946864 + ], + [ + 34.8103294373075, + -0.35494199412092 + ], + [ + 34.811920165839, + -0.361380994683084 + ], + [ + 34.8153495787066, + -0.367538988586328 + ], + [ + 34.822010040142, + -0.375279009500654 + ], + [ + 34.8231887818121, + -0.388823986316104 + ], + [ + 34.8349685665297, + -0.390866011737571 + ], + [ + 34.8395805358943, + -0.39437299970995 + ], + [ + 34.8547096251044, + -0.393049001816052 + ], + [ + 34.8581581113839, + -0.393871993094201 + ], + [ + 34.8591995235789, + -0.396349012801326 + ], + [ + 34.8614196776106, + -0.397379994409412 + ], + [ + 34.864028930384, + -0.397596001585913 + ], + [ + 34.8667411800724, + -0.39584100258521 + ], + [ + 34.8800506592569, + -0.398986011932382 + ], + [ + 34.882549285679, + -0.39778301152296 + ], + [ + 34.8892898558066, + -0.404336005388276 + ], + [ + 34.8909606930819, + -0.403268993253184 + ], + [ + 34.8915901180646, + -0.399742990849798 + ], + [ + 34.8960609436001, + -0.398467004451745 + ], + [ + 34.9023208614997, + -0.393781006539643 + ], + [ + 34.9141883848943, + -0.389872014812091 + ], + [ + 34.9129295347827, + -0.397105991776296 + ], + [ + 34.9135284423775, + -0.406210988992565 + ], + [ + 34.9157791136356, + -0.408109992857834 + ], + [ + 34.9262809752293, + -0.406415999172597 + ], + [ + 34.9296684262365, + -0.415033996126971 + ], + [ + 34.9339103697409, + -0.415926993119768 + ], + [ + 34.9367218016871, + -0.414869993979198 + ], + [ + 34.9400482174569, + -0.417192012124249 + ], + [ + 34.9476394649912, + -0.418792009368722 + ], + [ + 34.9588203430649, + -0.419268012259248 + ], + [ + 34.964580535695, + -0.414997011740574 + ], + [ + 34.9682312010619, + -0.416606009222934 + ], + [ + 34.9742088316808, + -0.414806008342675 + ], + [ + 34.9797897336172, + -0.416812002955495 + ], + [ + 34.9864196774759, + -0.416004985686217 + ], + [ + 34.9888610840591, + -0.41196200288656 + ], + [ + 35.0020790099141, + -0.408315003142937 + ], + [ + 35.0098304748598, + -0.393557995512931 + ], + [ + 35.0165290828926, + -0.400588989310594 + ], + [ + 35.020030975015, + -0.407977998203072 + ], + [ + 35.0201301573808, + -0.413237988913887 + ], + [ + 35.0087089539056, + -0.422464013032612 + ], + [ + 34.984081268035, + -0.435945004299202 + ], + [ + 34.9849891660104, + -0.438230991460368 + ], + [ + 34.9612197872309, + -0.448563993126587 + ], + [ + 34.9566688537146, + -0.453774988592296 + ], + [ + 34.9549102782375, + -0.452564001210013 + ], + [ + 34.9318504333034, + -0.470339000564194 + ], + [ + 34.913478851067, + -0.478635013276366 + ], + [ + 34.9130516050926, + -0.481783002938289 + ], + [ + 34.903900146467, + -0.484786987561828 + ], + [ + 34.9031181333965, + -0.482174992638721 + ], + [ + 34.8889503477, + -0.486799001910646 + ], + [ + 34.8890991210648, + -0.488941997632412 + ], + [ + 34.8768196104796, + -0.494208991844917 + ], + [ + 34.8734207150136, + -0.492906988010304 + ], + [ + 34.8568992612835, + -0.506024003234788 + ], + [ + 34.844890594425, + -0.513670027293837 + ], + [ + 34.8381004330379, + -0.516238987710852 + ], + [ + 34.8374481199198, + -0.518221974371861 + ], + [ + 34.8363800047042, + -0.516646981491657 + ], + [ + 34.8180885311651, + -0.519816994747283 + ], + [ + 34.8184700009565, + -0.523418009515079 + ], + [ + 34.8106613157707, + -0.526766002395344 + ], + [ + 34.8081092832507, + -0.524913012938803 + ], + [ + 34.791728973295, + -0.533563017878239 + ], + [ + 34.7921409604513, + -0.536637008237299 + ], + [ + 34.7841415404687, + -0.54204797741411 + ], + [ + 34.7818489074306, + -0.539662003610068 + ], + [ + 34.7724494933551, + -0.543299019671276 + ], + [ + 34.7598190305008, + -0.551342010749138 + ], + [ + 34.7590904232552, + -0.554727018199034 + ], + [ + 34.7576103208967, + -0.552761972309827 + ], + [ + 34.7526588440351, + -0.557133019010684 + ], + [ + 34.7405891414782, + -0.55939000844221 + ], + [ + 34.7276000973846, + -0.556751013019578 + ], + [ + 34.7248611448276, + -0.55805397054927 + ], + [ + 34.7239189148249, + -0.562395990012802 + ], + [ + 34.7205581663754, + -0.563807010858834 + ], + [ + 34.7084693905658, + -0.56334197550079 + ], + [ + 34.7025794979385, + -0.57107502236711 + ], + [ + 34.7027893065484, + -0.574231028654441 + ], + [ + 34.7006187437383, + -0.578173995100854 + ], + [ + 34.6939506531515, + -0.585781991773064 + ], + [ + 34.6956901546825, + -0.588460028169261 + ], + [ + 34.6820793151083, + -0.605611026354928 + ], + [ + 34.6772689817705, + -0.607928991417576 + ], + [ + 34.6687088010492, + -0.608528018135938 + ], + [ + 34.6579513547959, + -0.608295023463937 + ], + [ + 34.6559104916652, + -0.606912016991926 + ], + [ + 34.6537895201871, + -0.609516978259065 + ], + [ + 34.6391716004, + -0.634738028207663 + ], + [ + 34.6383705139055, + -0.637398004544013 + ], + [ + 34.6397781372785, + -0.638979017954337 + ], + [ + 34.6343193051887, + -0.665705025246125 + ], + [ + 34.6205406186134, + -0.658393025710317 + ], + [ + 34.6131210324846, + -0.656325995932842 + ], + [ + 34.5904312130281, + -0.654632985651808 + ], + [ + 34.581218719295, + -0.654644012660536 + ], + [ + 34.5782089233828, + -0.661484003434846 + ], + [ + 34.5757789609572, + -0.663455009692252 + ], + [ + 34.5589103696885, + -0.668734014040843 + ], + [ + 34.5568008421623, + -0.676910996394468 + ], + [ + 34.5484886165825, + -0.689158976147545 + ], + [ + 34.546669006078, + -0.691166996901663 + ], + [ + 34.5432510376571, + -0.691955983595508 + ], + [ + 34.5411796568859, + -0.694706976771434 + ], + [ + 34.5331611633244, + -0.710030973139712 + ], + [ + 34.5330390927199, + -0.738547981046071 + ], + [ + 34.5311889646065, + -0.74408400068167 + ], + [ + 34.5243301391048, + -0.751465976566904 + ], + [ + 34.523319244073, + -0.755219996094406 + ], + [ + 34.4829406738704, + -0.790274977619968 + ], + [ + 34.481559753165, + -0.802295029175752 + ], + [ + 34.4693298338285, + -0.825273990933598 + ], + [ + 34.4647102352386, + -0.837071001531768 + ], + [ + 34.4626693723591, + -0.851777970927684 + ], + [ + 34.463150024297, + -0.86771601449314 + ], + [ + 34.439430236495, + -0.847472012212406 + ], + [ + 34.4319496151321, + -0.84376698732516 + ], + [ + 34.427528381048, + -0.838150978357247 + ], + [ + 34.4123306274858, + -0.830712020621397 + ], + [ + 34.403789520136, + -0.824156999657325 + ], + [ + 34.3919181822002, + -0.820127010642739 + ], + [ + 34.3891296386327, + -0.817822992967247 + ], + [ + 34.385238647331, + -0.822346985545578 + ], + [ + 34.3878517148165, + -0.828027010274183 + ], + [ + 34.3914909362143, + -0.830556988990309 + ], + [ + 34.3912887569553, + -0.832229972125801 + ], + [ + 34.3883514403493, + -0.835080981457174 + ], + [ + 34.3813896176497, + -0.847465992037647 + ], + [ + 34.3720703123242, + -0.849992990548269 + ], + [ + 34.3659706116052, + -0.855206012842816 + ], + [ + 34.3547210691039, + -0.853002011902929 + ], + [ + 34.3498115536549, + -0.850507974769047 + ], + [ + 34.3469085691785, + -0.850807011304048 + ], + [ + 34.3483581542548, + -0.857419014179398 + ], + [ + 34.3378715512162, + -0.857151985548191 + ], + [ + 34.3353996275051, + -0.861966013846852 + ], + [ + 34.3323783872629, + -0.864336014174842 + ], + [ + 34.3219299313395, + -0.866934001400902 + ], + [ + 34.3207893371094, + -0.869368016840148 + ], + [ + 34.3120613097195, + -0.862017989402179 + ], + [ + 34.2967910765639, + -0.841571986879134 + ], + [ + 34.2831993100554, + -0.829490006164039 + ], + [ + 34.2800292969425, + -0.827283978678287 + ], + [ + 34.2706985473092, + -0.826963007798812 + ], + [ + 34.255760192745, + -0.823557973140846 + ], + [ + 34.2516403194844, + -0.819657981545666 + ], + [ + 34.2500114439606, + -0.813898980606246 + ], + [ + 34.2352981564387, + -0.796841025397369 + ], + [ + 34.2297096252092, + -0.785510003755067 + ], + [ + 34.2168884277723, + -0.774847984475694 + ], + [ + 34.2137908932448, + -0.773727000078476 + ], + [ + 34.2022590634571, + -0.756389021871654 + ], + [ + 34.1978187557433, + -0.755196988621768 + ], + [ + 34.1961212158267, + -0.755307019070076 + ], + [ + 34.1938896178212, + -0.763637006417354 + ], + [ + 34.1874885558975, + -0.769863009458919 + ], + [ + 34.1785888671669, + -0.768164992467638 + ], + [ + 34.1735115048629, + -0.775547981240602 + ], + [ + 34.1637001037083, + -0.777657985893913 + ], + [ + 34.146179199202, + -0.78438401217971 + ], + [ + 34.1350784301819, + -0.790953993738234 + ], + [ + 34.1248512266192, + -0.794811010392652 + ], + [ + 34.1202583310897, + -0.787449002496724 + ], + [ + 34.1031608581006, + -0.787481009961492 + ], + [ + 34.0980987548552, + -0.789156019846152 + ], + [ + 34.0904998777627, + -0.793381989008938 + ], + [ + 34.0821304321186, + -0.805714011166162 + ], + [ + 34.0756301878191, + -0.807959020223218 + ], + [ + 34.0731887813577, + -0.805734992162836 + ], + [ + 34.0713806149705, + -0.799772024263116 + ], + [ + 34.070911407347, + -0.795912028064797 + ], + [ + 34.0761718746124, + -0.788185000296319 + ], + [ + 34.0791702268929, + -0.786366999075553 + ], + [ + 34.0781211854283, + -0.77315700064691 + ], + [ + 34.0756797788177, + -0.770870983553066 + ], + [ + 34.0727310176464, + -0.771928012775884 + ], + [ + 34.0707702635395, + -0.774968982057692 + ], + [ + 34.0678100581323, + -0.776280999365201 + ], + [ + 34.0652008054693, + -0.775638997715759 + ], + [ + 34.0682983398867, + -0.769587993507218 + ], + [ + 34.0722885124991, + -0.765767992213887 + ], + [ + 34.0734596248886, + -0.75789797296986 + ], + [ + 34.0711402893946, + -0.742384017023116 + ], + [ + 34.0682716362661, + -0.738946974186464 + ], + [ + 34.0656585691013, + -0.731875002658081 + ], + [ + 34.0589599610651, + -0.72876399809079 + ], + [ + 34.0561790467475, + -0.7291809921991 + ], + [ + 34.0511894221542, + -0.732782006194536 + ], + [ + 34.0431709283489, + -0.734619022046682 + ], + [ + 34.0432586663373, + -0.739524007362461 + ], + [ + 34.0412483211126, + -0.742564022830797 + ], + [ + 34.0390701292639, + -0.743134022115547 + ], + [ + 34.0375518798052, + -0.741271973341088 + ], + [ + 34.0341415399172, + -0.742411017949055 + ], + [ + 34.0290107721199, + -0.740079999563629 + ], + [ + 34.0344581600094, + -0.738240004025029 + ], + [ + 34.0370407099569, + -0.735108971714719 + ], + [ + 34.0365409849073, + -0.729981005287054 + ], + [ + 34.0402297971026, + -0.730431974673244 + ], + [ + 34.0455207822751, + -0.72615200287414 + ], + [ + 34.0493698114945, + -0.72751700888947 + ], + [ + 34.0549507137005, + -0.722702980450744 + ], + [ + 34.057571411253, + -0.717764974292993 + ], + [ + 34.0569000236933, + -0.714806020454154 + ], + [ + 34.04867935143, + -0.710771978655859 + ], + [ + 34.0502204887992, + -0.701833010373213 + ], + [ + 34.0476188661232, + -0.696263015386548 + ], + [ + 34.0508689874669, + -0.688364028892766 + ], + [ + 34.0486183167265, + -0.685244023969903 + ], + [ + 34.0509605408343, + -0.682855010357811 + ], + [ + 34.0527191162991, + -0.682901025140196 + ], + [ + 34.05387115413, + -0.67493999052484 + ], + [ + 34.0474395745586, + -0.666799008970179 + ], + [ + 34.0528297425442, + -0.653914988114916 + ], + [ + 34.0501098632817, + -0.649474978372914 + ], + [ + 34.0444602961453, + -0.650641978499759 + ], + [ + 34.0443992613612, + -0.648643016910792 + ], + [ + 34.0485992431052, + -0.645349979282076 + ], + [ + 34.050479888771, + -0.645856023060513 + ], + [ + 34.0521888733874, + -0.63608497387884 + ], + [ + 34.0674095147023, + -0.634290993351509 + ], + [ + 34.0785484311953, + -0.62570297740823 + ], + [ + 34.088008880649, + -0.611697971865403 + ], + [ + 34.0916709895808, + -0.610395014213498 + ], + [ + 34.0937614438246, + -0.605754018462416 + ], + [ + 34.0918388365711, + -0.600144982953824 + ], + [ + 34.0879898064917, + -0.596826971219836 + ], + [ + 34.0745315544818, + -0.597922981413904 + ], + [ + 34.0739402768513, + -0.595815003636328 + ], + [ + 34.0774497980607, + -0.5891940001894 + ], + [ + 34.0760116570081, + -0.586480021509032 + ], + [ + 34.0684509274621, + -0.583840012666918 + ], + [ + 34.0651397701482, + -0.576095999009033 + ], + [ + 34.0606918334776, + -0.572252988952719 + ], + [ + 34.0639114377092, + -0.567799985398557 + ], + [ + 34.0726585385331, + -0.56365597311954 + ], + [ + 34.0796394349264, + -0.56975299151503 + ], + [ + 34.083549499657, + -0.571188987051289 + ], + [ + 34.0907783504521, + -0.570609986860741 + ], + [ + 34.1054115294824, + -0.559733987424752 + ], + [ + 34.107849120367, + -0.552416026847594 + ], + [ + 34.1064682007827, + -0.547809005070191 + ], + [ + 34.1071090695246, + -0.543847978198801 + ], + [ + 34.1143112175524, + -0.536817014212047 + ], + [ + 34.1167907714685, + -0.539178014319734 + ], + [ + 34.1197319029813, + -0.550728977437468 + ], + [ + 34.1235885614383, + -0.552347004589108 + ], + [ + 34.1289405823148, + -0.551360011161143 + ], + [ + 34.1317291253374, + -0.552417993454294 + ], + [ + 34.1394081113554, + -0.548582017566871 + ], + [ + 34.1423187250372, + -0.544880986087096 + ], + [ + 34.144321441609, + -0.545468986685797 + ], + [ + 34.1475105283877, + -0.543361008615654 + ], + [ + 34.152400970355, + -0.543421984053153 + ], + [ + 34.156341552752, + -0.545683026385006 + ], + [ + 34.1595382683595, + -0.540082991103644 + ], + [ + 34.1603202817098, + -0.515251994711433 + ], + [ + 34.1581497192617, + -0.504496992292562 + ], + [ + 34.1604881281574, + -0.499439001384675 + ], + [ + 34.1609497063407, + -0.489695996220955 + ], + [ + 34.163379669048, + -0.483860999881784 + ], + [ + 34.1732902527968, + -0.470301002784427 + ], + [ + 34.1869506834279, + -0.460844010098408 + ], + [ + 34.1926994320973, + -0.454766006007449 + ], + [ + 34.2032890318822, + -0.4207769933714 + ], + [ + 34.2061386102449, + -0.424387008706865 + ], + [ + 34.2087097168159, + -0.431968003499942 + ], + [ + 34.2182998652968, + -0.438279002810108 + ], + [ + 34.2254219053305, + -0.436731010872808 + ], + [ + 34.2365684502905, + -0.441911995742642 + ], + [ + 34.2524681086205, + -0.436401009552138 + ], + [ + 34.2529907221092, + -0.44451698703856 + ], + [ + 34.2594184869813, + -0.446025997923519 + ], + [ + 34.2629013060622, + -0.452048987886204 + ], + [ + 34.2688789366629, + -0.45137000094824 + ], + [ + 34.2703590387349, + -0.455511004157731 + ], + [ + 34.2741394036691, + -0.458741009483158 + ], + [ + 34.280330657895, + -0.456106007033767 + ], + [ + 34.2824592583154, + -0.45748299434674 + ], + [ + 34.2834892273559, + -0.459463000604624 + ], + [ + 34.2800407402638, + -0.460276991155232 + ], + [ + 34.2789001466036, + -0.463705003632667 + ], + [ + 34.283550261735, + -0.469538987016963 + ], + [ + 34.2834396362384, + -0.473872006067547 + ], + [ + 34.2976913450659, + -0.475064009430141 + ], + [ + 34.3135986329124, + -0.480857998356647 + ], + [ + 34.3105201715903, + -0.474254995573612 + ], + [ + 34.3003311153427, + -0.467819005557572 + ], + [ + 34.3049507136834, + -0.463645995390642 + ], + [ + 34.3085594172338, + -0.463880002625653 + ], + [ + 34.315311431233, + -0.459791988851047 + ], + [ + 34.317020415631, + -0.451388002025403 + ], + [ + 34.3193092341998, + -0.450635999371566 + ], + [ + 34.3215293879414, + -0.453613012918267 + ], + [ + 34.3230705258005, + -0.463761985581871 + ], + [ + 34.3267593379654, + -0.468165010196024 + ], + [ + 34.3342285152181, + -0.471148014610527 + ], + [ + 34.3357505794744, + -0.473010987297488 + ], + [ + 34.3354301451036, + -0.4886249906958 + ], + [ + 34.3418083185914, + -0.493146986526297 + ], + [ + 34.3470687859187, + -0.500118971602229 + ], + [ + 34.3529014582467, + -0.493514001737942 + ], + [ + 34.3521194456529, + -0.483509987777601 + ], + [ + 34.366508483601, + -0.479157001339806 + ], + [ + 34.3675308224165, + -0.476034999154943 + ], + [ + 34.3668594355767, + -0.464170009646362 + ], + [ + 34.3624382015901, + -0.459681987671771 + ], + [ + 34.3632583617092, + -0.456544012204122 + ], + [ + 34.3678703301685, + -0.453404993492671 + ], + [ + 34.3700981136261, + -0.449063987204993 + ], + [ + 34.3764991752945, + -0.456070005798174 + ], + [ + 34.3916893006763, + -0.458047985911118 + ], + [ + 34.401031494224, + -0.456772000208509 + ], + [ + 34.4029693602712, + -0.467581987417169 + ], + [ + 34.4088096617819, + -0.469994991905746 + ], + [ + 34.4044914238342, + -0.472618013842378 + ], + [ + 34.4040718075724, + -0.475937009620412 + ], + [ + 34.4056816096003, + -0.479736000529644 + ], + [ + 34.4168281556415, + -0.486526012354353 + ], + [ + 34.4182815549457, + -0.498194993204119 + ], + [ + 34.4238395691904, + -0.497153014227329 + ], + [ + 34.4309196471564, + -0.492648006171129 + ], + [ + 34.4334793087006, + -0.493081003646959 + ], + [ + 34.4351005552834, + -0.49733999363866 + ], + [ + 34.4360809325886, + -0.50118499989729 + ], + [ + 34.434089660598, + -0.510565996614679 + ], + [ + 34.424320220237, + -0.509644985527284 + ], + [ + 34.4156608576732, + -0.522058010291885 + ], + [ + 34.4154510495269, + -0.533038020703262 + ], + [ + 34.4202499384653, + -0.538254976419576 + ], + [ + 34.4232292167982, + -0.537786007495437 + ], + [ + 34.43722915608, + -0.530489981734109 + ], + [ + 34.4576988221713, + -0.52347499174317 + ], + [ + 34.4714088432659, + -0.515331984031223 + ], + [ + 34.4840393059932, + -0.510987997642402 + ], + [ + 34.486560821311, + -0.508825003996875 + ], + [ + 34.4892196652765, + -0.502313018317028 + ], + [ + 34.4898605347394, + -0.496461004262265 + ], + [ + 34.4885215756286, + -0.494433999326097 + ], + [ + 34.4913215631515, + -0.492246002432743 + ], + [ + 34.4953498838338, + -0.493075997261786 + ], + [ + 34.4951210022943, + -0.491421014145014 + ], + [ + 34.4967803952732, + -0.490705997263222 + ], + [ + 34.5017700192491, + -0.491519004300737 + ], + [ + 34.5097999569706, + -0.474269002690856 + ], + [ + 34.4976615900979, + -0.461302995572214 + ], + [ + 34.4897499083798, + -0.457078993406483 + ], + [ + 34.4789581294039, + -0.445430994406663 + ], + [ + 34.4715805049799, + -0.441020995268441 + ], + [ + 34.466499328408, + -0.432491988560971 + ], + [ + 34.4506492612813, + -0.415082007832338 + ], + [ + 34.4478988645194, + -0.403017997841618 + ], + [ + 34.4443511956121, + -0.398584991931709 + ], + [ + 34.4436111447052, + -0.39542201172729 + ], + [ + 34.4468612671222, + -0.391503989593715 + ], + [ + 34.4472808838166, + -0.385053992888977 + ], + [ + 34.4562110896362, + -0.367188007367484 + ], + [ + 34.4594993588715, + -0.354263008206138 + ], + [ + 34.4777793883361, + -0.346300005779269 + ], + [ + 34.4901199339982, + -0.34630900690649 + ], + [ + 34.4986915589147, + -0.339442015196204 + ], + [ + 34.5182800293198, + -0.333279013679969 + ], + [ + 34.5369796747646, + -0.328709006646547 + ], + [ + 34.5505218502734, + -0.328009992847082 + ], + [ + 34.5661201477624, + -0.330810994514755 + ], + [ + 34.5712890621413, + -0.336508989801025 + ], + [ + 34.5864715573582, + -0.3466799859601 + ], + [ + 34.5956306454186, + -0.348459988858955 + ], + [ + 34.6072006223563, + -0.348713010832994 + ], + [ + 34.6107215880723, + -0.343385011144085 + ], + [ + 34.6141014098723, + -0.341919005308692 + ], + [ + 34.6267089844781, + -0.340615987849872 + ], + [ + 34.6286888119123, + -0.341156005802005 + ], + [ + 34.6325187675872, + -0.350391001042175 + ], + [ + 34.6447486877118, + -0.352214009250388 + ], + [ + 34.6584892268869, + -0.356934011228453 + ], + [ + 34.6620483394615, + -0.356644988285097 + ], + [ + 34.6638908386978, + -0.354889989433075 + ], + [ + 34.6727981561785, + -0.357482999764506 + ], + [ + 34.6768302916616, + -0.356974989931195 + ], + [ + 34.6861915588006, + -0.352155000077462 + ], + [ + 34.6959991451256, + -0.354178995010005 + ], + [ + 34.6998481746875, + -0.353742986990006 + ], + [ + 34.7048492426555, + -0.350946993123794 + ], + [ + 34.7092781065951, + -0.354761988308264 + ], + [ + 34.7306289671357, + -0.356649011863722 + ], + [ + 34.7413291932561, + -0.352975011359628 + ], + [ + 34.7559013363737, + -0.352964014576499 + ], + [ + 34.7630996703498, + -0.336782008282447 + ], + [ + 34.763130187823, + -0.327603996050633 + ], + [ + 34.7587394710526, + -0.322838008741381 + ], + [ + 34.7507514951339, + -0.320596009467681 + ], + [ + 34.7471885678729, + -0.312557012604734 + ], + [ + 34.7473487852729, + -0.308162987982089 + ], + [ + 34.7454299924869, + -0.304329008317231 + ], + [ + 34.7516098015184, + -0.300310999323709 + ], + [ + 34.7524299622871, + -0.293628990558029 + ], + [ + 34.7575492852908, + -0.300999999306465 + ], + [ + 34.7566299433373, + -0.308676005154477 + ], + [ + 34.7582206719739, + -0.31252101087114 + ] + ] + ], + [ + [ + [ + 34.3994903560935, + -0.254267991244887 + ], + [ + 34.3970108032719, + -0.253908008974494 + ], + [ + 34.4013214111923, + -0.251022995196485 + ], + [ + 34.4017791743855, + -0.252530992768988 + ], + [ + 34.3994903560935, + -0.254267991244887 + ] + ] + ], + [ + [ + [ + 34.1367187496645, + -0.24409900678151 + ], + [ + 34.1348915094622, + -0.246243000392158 + ], + [ + 34.1344490048806, + -0.241639003507447 + ], + [ + 34.1362686151219, + -0.241992995808608 + ], + [ + 34.1367187496645, + -0.24409900678151 + ] + ] + ], + [ + [ + [ + 34.1424903865964, + -0.226278006942752 + ], + [ + 34.1440010069603, + -0.227889001630092 + ], + [ + 34.1479988097435, + -0.227724999554172 + ], + [ + 34.1474494933468, + -0.233108998002222 + ], + [ + 34.1487312313635, + -0.235117003691857 + ], + [ + 34.1431007386267, + -0.241015002285198 + ], + [ + 34.1433792107537, + -0.245220005740591 + ], + [ + 34.1419181823993, + -0.245383993358565 + ], + [ + 34.1405982970632, + -0.243122995248227 + ], + [ + 34.141139983766, + -0.23482699730886 + ], + [ + 34.1335601804736, + -0.231389999441659 + ], + [ + 34.1395797731, + -0.230031997591843 + ], + [ + 34.1402587885971, + -0.227228999215948 + ], + [ + 34.1424903865964, + -0.226278006942752 + ] + ] + ], + [ + [ + [ + 34.1140289307814, + -0.214855000728845 + ], + [ + 34.1142387386113, + -0.221377998995979 + ], + [ + 34.1170005791669, + -0.224905997762796 + ], + [ + 34.114749908376, + -0.225765005461102 + ], + [ + 34.1110191342269, + -0.223196998939716 + ], + [ + 34.1095314022483, + -0.220057994860915 + ], + [ + 34.1094894406354, + -0.21593299523273 + ], + [ + 34.1140289307814, + -0.214855000728845 + ] + ] + ], + [ + [ + [ + 34.4601287838158, + -0.215441004048315 + ], + [ + 34.4576797481487, + -0.217079997201956 + ], + [ + 34.4563102723182, + -0.215496004321451 + ], + [ + 34.4568099972457, + -0.213398993618589 + ], + [ + 34.4675712579274, + -0.2100050007577 + ], + [ + 34.4601287838158, + -0.215441004048315 + ] + ] + ], + [ + [ + [ + 34.518421173039, + -0.206698000877984 + ], + [ + 34.5200386045181, + -0.20788300095285 + ], + [ + 34.5236816405419, + -0.206997007583868 + ], + [ + 34.5239410394751, + -0.208343998064179 + ], + [ + 34.5185508722993, + -0.213635995966025 + ], + [ + 34.5161590576878, + -0.214614004630746 + ], + [ + 34.5155715941595, + -0.211816997059059 + ], + [ + 34.5119209287706, + -0.2104970066698 + ], + [ + 34.5082283014411, + -0.216396004245904 + ], + [ + 34.5004692075734, + -0.213556006777153 + ], + [ + 34.4984397885346, + -0.218287006036259 + ], + [ + 34.4964790342599, + -0.217318997325647 + ], + [ + 34.4956092831303, + -0.212118000349885 + ], + [ + 34.502929687432, + -0.200975001600127 + ], + [ + 34.509170532324, + -0.196326002751571 + ], + [ + 34.5174217224807, + -0.194516003818882 + ], + [ + 34.5191917419178, + -0.196198002003704 + ], + [ + 34.5162200921552, + -0.202952996325382 + ], + [ + 34.518421173039, + -0.206698000877984 + ] + ] + ], + [ + [ + [ + 34.0568504333938, + -0.192895994180268 + ], + [ + 34.055400848418, + -0.193039000564602 + ], + [ + 34.0549697873727, + -0.191231996185407 + ], + [ + 34.0574417114117, + -0.189449995967354 + ], + [ + 34.0568504333938, + -0.192895994180268 + ] + ] + ], + [ + [ + [ + 34.5378608703482, + -0.183923006587942 + ], + [ + 34.5444297783191, + -0.186717003994445 + ], + [ + 34.5436210627033, + -0.18843500334416 + ], + [ + 34.5395889277135, + -0.189955994372752 + ], + [ + 34.5374298094948, + -0.188463002717202 + ], + [ + 34.536289215183, + -0.186029002680658 + ], + [ + 34.5378608703482, + -0.183923006587942 + ] + ] + ], + [ + [ + [ + 34.5419311520741, + -0.177554995459931 + ], + [ + 34.5372085568702, + -0.177201002756337 + ], + [ + 34.5354614251704, + -0.175465003046841 + ], + [ + 34.5363998405701, + -0.1735209974197 + ], + [ + 34.5393600456777, + -0.172552004755814 + ], + [ + 34.5441207884934, + -0.174659997249441 + ], + [ + 34.5419311520741, + -0.177554995459931 + ] + ] + ], + [ + [ + [ + 34.5511016838423, + -0.182683006154941 + ], + [ + 34.5497207642557, + -0.183956995819815 + ], + [ + 34.5482292169827, + -0.18155199332737 + ], + [ + 34.5453987115703, + -0.172923997004285 + ], + [ + 34.5487403871132, + -0.171819999859916 + ], + [ + 34.5495491024781, + -0.173067003763431 + ], + [ + 34.5491409300549, + -0.176477000202428 + ], + [ + 34.5524291988164, + -0.179000005227063 + ], + [ + 34.5511016838423, + -0.182683006154941 + ] + ] + ], + [ + [ + [ + 34.6083106991855, + -0.160131007388101 + ], + [ + 34.6149406427355, + -0.159730002525364 + ], + [ + 34.608940123785, + -0.166026994763317 + ], + [ + 34.6068801877634, + -0.165728003413682 + ], + [ + 34.6035194389719, + -0.169064999321015 + ], + [ + 34.6028289792516, + -0.16241699502844 + ], + [ + 34.6003990167273, + -0.160761997604623 + ], + [ + 34.6007881163203, + -0.158963993230258 + ], + [ + 34.6083106991855, + -0.160131007388101 + ] + ] + ], + [ + [ + [ + 34.045108794793, + -0.135089994200292 + ], + [ + 34.043380737124, + -0.136049002725151 + ], + [ + 34.0413894648103, + -0.133091002945988 + ], + [ + 34.0467910759328, + -0.131053999547496 + ], + [ + 34.0495986939957, + -0.132810995049119 + ], + [ + 34.045108794793, + -0.135089994200292 + ] + ] + ], + [ + [ + [ + 34.0102806089366, + -0.128078997777673 + ], + [ + 34.0132789612483, + -0.128405005153257 + ], + [ + 34.0161590573009, + -0.126378998698566 + ], + [ + 34.0177001950656, + -0.130412996241417 + ], + [ + 34.0238304137994, + -0.133670002439227 + ], + [ + 34.0317001341465, + -0.128378004044975 + ], + [ + 34.0367202754878, + -0.126586005443822 + ], + [ + 34.0388107294861, + -0.127607003204381 + ], + [ + 34.0369911191701, + -0.130215004542825 + ], + [ + 34.0267601010053, + -0.135308995968923 + ], + [ + 34.0240211487638, + -0.141975000926457 + ], + [ + 34.0196914668423, + -0.140771999875237 + ], + [ + 34.0166702266165, + -0.143105999013996 + ], + [ + 34.0095405575111, + -0.141286999235816 + ], + [ + 34.00308990427, + -0.136683002556363 + ], + [ + 34.0004692078828, + -0.137262002154141 + ], + [ + 33.9991111751425, + -0.139939993995134 + ], + [ + 33.9956207269556, + -0.134287998315211 + ], + [ + 33.9916610713048, + -0.133346006817162 + ], + [ + 33.9866561887919, + -0.136118993785121 + ], + [ + 33.9867858880363, + -0.127811998463105 + ], + [ + 33.9913787837895, + -0.126678004990381 + ], + [ + 33.9958686828561, + -0.121123001650573 + ], + [ + 34.0060005185283, + -0.124252997846045 + ], + [ + 34.0102806089366, + -0.128078997777673 + ] + ] + ], + [ + [ + [ + 34.0999984741821, + -0.135629996806123 + ], + [ + 34.0973281854461, + -0.13674199632561 + ], + [ + 34.0929489136276, + -0.134572998173591 + ], + [ + 34.0923995964831, + -0.129841000467649 + ], + [ + 34.0875015256742, + -0.126992002052066 + ], + [ + 34.0911407468937, + -0.124214001415072 + ], + [ + 34.0930213922766, + -0.120668001512562 + ], + [ + 34.097450255924, + -0.122630000202121 + ], + [ + 34.1001281732362, + -0.127587005499911 + ], + [ + 34.0999984741821, + -0.135629996806123 + ] + ] + ] + ], + "type": "MultiPolygon" +} diff --git a/tests/api/helpers/test_files/search_bbox_result.json b/tests/api/helpers/test_files/search_bbox_result.json new file mode 100644 index 0000000000..6e24b77383 --- /dev/null +++ b/tests/api/helpers/test_files/search_bbox_result.json @@ -0,0 +1,2930 @@ +{ + "features": [ + { + "geometry": { + "coordinates": [ + [ + [ + [ + 34.0893707268651, + -0.536513984601753 + ], + [ + 34.0837097165999, + -0.535368025180619 + ], + [ + 34.0824012756666, + -0.522611022751177 + ], + [ + 34.0835189818563, + -0.521118998858031 + ], + [ + 34.0859489436038, + -0.520412028506349 + ], + [ + 34.0887794491696, + -0.522691011775293 + ], + [ + 34.0938186647061, + -0.53062498601422 + ], + [ + 34.0922393800162, + -0.534533023725973 + ], + [ + 34.0893707268651, + -0.536513984601753 + ] + ] + ], + [ + [ + [ + 34.0964317315364, + -0.473861009201641 + ], + [ + 34.0903701783458, + -0.477777987607268 + ], + [ + 34.0894088743771, + -0.480300993106367 + ], + [ + 34.0840606689686, + -0.483107000623554 + ], + [ + 34.0832290642053, + -0.485565990460461 + ], + [ + 34.0777206420479, + -0.488045990479494 + ], + [ + 34.0756301877947, + -0.48638200784417 + ], + [ + 34.0795097347646, + -0.483161986306938 + ], + [ + 34.0812988279751, + -0.477977007985046 + ], + [ + 34.0807914731244, + -0.474739998452093 + ], + [ + 34.0869102477411, + -0.474655986304899 + ], + [ + 34.0935516357163, + -0.469579995295603 + ], + [ + 34.0973091126488, + -0.472213000194603 + ], + [ + 34.0964317315364, + -0.473861009201641 + ] + ] + ], + [ + [ + [ + 33.9454116820645, + -0.463559001916262 + ], + [ + 33.9395599365248, + -0.463423997083882 + ], + [ + 33.9403190610894, + -0.459735006029653 + ], + [ + 33.945270538096, + -0.461468995234408 + ], + [ + 33.9454116820645, + -0.463559001916262 + ] + ] + ], + [ + [ + [ + 33.955379485646, + -0.439756006467532 + ], + [ + 33.9534111015895, + -0.441495001379958 + ], + [ + 33.9522895809163, + -0.436744988255081 + ], + [ + 33.9562606813082, + -0.436861009014392 + ], + [ + 33.955379485646, + -0.439756006467532 + ] + ] + ], + [ + [ + [ + 34.0426788324311, + -0.441617995922242 + ], + [ + 34.0579109193315, + -0.454786986694148 + ], + [ + 34.0618209834879, + -0.466004014204983 + ], + [ + 34.0680007929779, + -0.475248992840064 + ], + [ + 34.0617904656388, + -0.483705997355769 + ], + [ + 34.0581703181331, + -0.486158997364131 + ], + [ + 34.0514488214515, + -0.48744401352108 + ], + [ + 34.0425796502369, + -0.492495000572894 + ], + [ + 34.0173301691978, + -0.49810600351348 + ], + [ + 33.9986495967674, + -0.499177009522071 + ], + [ + 33.9948692319355, + -0.495505989061121 + ], + [ + 33.9925994866689, + -0.489533991119976 + ], + [ + 33.9869308467725, + -0.485076010336786 + ], + [ + 33.9746284482024, + -0.482282013259886 + ], + [ + 33.9698791503386, + -0.478166014668594 + ], + [ + 33.9621696468858, + -0.480320007289396 + ], + [ + 33.959869384821, + -0.4795329870708 + ], + [ + 33.9616203304768, + -0.476166993823649 + ], + [ + 33.9591789242271, + -0.474866003235944 + ], + [ + 33.9557609557607, + -0.476051002849935 + ], + [ + 33.9492187498746, + -0.472840011421986 + ], + [ + 33.9448890686212, + -0.473563998816022 + ], + [ + 33.9456787106334, + -0.471403986181399 + ], + [ + 33.9551696776208, + -0.467249006781304 + ], + [ + 33.9556808469152, + -0.463665991905308 + ], + [ + 33.9537200921702, + -0.46022900949028 + ], + [ + 33.9546508785988, + -0.458112001448788 + ], + [ + 33.9502983093133, + -0.45217698870175 + ], + [ + 33.9588890074224, + -0.451597989312783 + ], + [ + 33.9601211547147, + -0.446150988264086 + ], + [ + 33.9670181272535, + -0.441754013979948 + ], + [ + 33.9700202941679, + -0.442532002917132 + ], + [ + 33.9751205437283, + -0.451126009810991 + ], + [ + 33.9788589470794, + -0.451622992748461 + ], + [ + 33.9860610955408, + -0.448074013291747 + ], + [ + 33.993858337062, + -0.441381007317147 + ], + [ + 33.9966392511954, + -0.441868007649385 + ], + [ + 34.0144500725981, + -0.436924994268909 + ], + [ + 34.0217704769388, + -0.426748991155693 + ], + [ + 34.0259399414171, + -0.424603999414845 + ], + [ + 34.0310096737433, + -0.425832003288011 + ], + [ + 34.0374183653533, + -0.436044991164478 + ], + [ + 34.0426788324311, + -0.441617995922242 + ] + ] + ], + [ + [ + [ + 34.2754516596662, + -0.401969999488986 + ], + [ + 34.2715797423912, + -0.401030987644172 + ], + [ + 34.2744903559876, + -0.400332987688116 + ], + [ + 34.2754516596662, + -0.401969999488986 + ] + ] + ], + [ + [ + [ + 34.2781715389658, + -0.381237000492777 + ], + [ + 34.2761688228689, + -0.382865995608465 + ], + [ + 34.2743492121104, + -0.381680995640269 + ], + [ + 34.2783508301769, + -0.379925996760625 + ], + [ + 34.2781715389658, + -0.381237000492777 + ] + ] + ], + [ + [ + [ + 34.2143287655156, + -0.395693004366286 + ], + [ + 34.2142906182445, + -0.405660004031797 + ], + [ + 34.2120018004263, + -0.411262006264649 + ], + [ + 34.204349517321, + -0.415695012092491 + ], + [ + 34.2029495239652, + -0.419582993174731 + ], + [ + 34.1996002197026, + -0.421483993822473 + ], + [ + 34.1877708433517, + -0.421559006677778 + ], + [ + 34.1794395440855, + -0.427493990015186 + ], + [ + 34.1756782531266, + -0.432850003983613 + ], + [ + 34.165271758304, + -0.438524991595183 + ], + [ + 34.1613998410085, + -0.444068014742153 + ], + [ + 34.1456985469489, + -0.429895997800304 + ], + [ + 34.1378898619166, + -0.428847998925689 + ], + [ + 34.136909484728, + -0.432891011472217 + ], + [ + 34.134300231592, + -0.433950991107749 + ], + [ + 34.1333389278868, + -0.437920988165511 + ], + [ + 34.1311607357573, + -0.438971996391205 + ], + [ + 34.1283798215211, + -0.437976003020405 + ], + [ + 34.1264610287079, + -0.433889001655333 + ], + [ + 34.1176605223996, + -0.427177011840722 + ], + [ + 34.1166992184456, + -0.422545999545391 + ], + [ + 34.1192703248463, + -0.411130011306752 + ], + [ + 34.1313514703541, + -0.415950000813917 + ], + [ + 34.1384315486929, + -0.412773013288862 + ], + [ + 34.1467704774138, + -0.40178999330445 + ], + [ + 34.1569900507969, + -0.399167001650706 + ], + [ + 34.1601295471043, + -0.394279987164107 + ], + [ + 34.160659789502, + -0.3878209894747 + ], + [ + 34.1580696104563, + -0.384274989420933 + ], + [ + 34.1581687924587, + -0.37681299451016 + ], + [ + 34.1667213436273, + -0.378331006020431 + ], + [ + 34.1715507505398, + -0.380257010370037 + ], + [ + 34.1740303033703, + -0.384128988337088 + ], + [ + 34.1842308041676, + -0.388287991819939 + ], + [ + 34.1941604611658, + -0.39040300288116 + ], + [ + 34.1969604491079, + -0.398047000614702 + ], + [ + 34.1994781489183, + -0.400009989819131 + ], + [ + 34.2037086481541, + -0.397612005717622 + ], + [ + 34.2089195247285, + -0.390827000545428 + ], + [ + 34.2076797480607, + -0.387234986055222 + ], + [ + 34.2105598449187, + -0.376715004601617 + ], + [ + 34.2103385922229, + -0.36443001056801 + ], + [ + 34.2191810601522, + -0.36015901038489 + ], + [ + 34.2211189268839, + -0.354171991759936 + ], + [ + 34.2236709588762, + -0.356640011645463 + ], + [ + 34.2278518671235, + -0.357789009821622 + ], + [ + 34.2276496881491, + -0.359632999264451 + ], + [ + 34.2177581787943, + -0.380856007496425 + ], + [ + 34.2143287655156, + -0.395693004366286 + ] + ] + ], + [ + [ + [ + 34.2518005369727, + -0.355477988879186 + ], + [ + 34.2492980956664, + -0.356083989530268 + ], + [ + 34.2500114441154, + -0.352638006396314 + ], + [ + 34.2546806336564, + -0.351561993650388 + ], + [ + 34.2518005369727, + -0.355477988879186 + ] + ] + ], + [ + [ + [ + 34.2636184686575, + -0.346350014132461 + ], + [ + 34.2605094907419, + -0.351669013877824 + ], + [ + 34.2555198663605, + -0.3504230086018 + ], + [ + 34.2567787165965, + -0.34663200411094 + ], + [ + 34.2636184686575, + -0.346350014132461 + ] + ] + ], + [ + [ + [ + 34.7582206719739, + -0.31252101087114 + ], + [ + 34.7583808897327, + -0.31669801508201 + ], + [ + 34.7630691524777, + -0.315403998108347 + ], + [ + 34.7676086423743, + -0.314383000305777 + ], + [ + 34.7735099793049, + -0.320845991625677 + ], + [ + 34.7772483822775, + -0.320601999839968 + ], + [ + 34.7792587277671, + -0.322156995636362 + ], + [ + 34.7803993223333, + -0.325801014855357 + ], + [ + 34.7830009457264, + -0.324263006498863 + ], + [ + 34.7873992919402, + -0.332888990771244 + ], + [ + 34.7825813293905, + -0.334661990704691 + ], + [ + 34.7857704159269, + -0.346581995440046 + ], + [ + 34.7891006467147, + -0.345259994365036 + ], + [ + 34.7910690307328, + -0.345991999112342 + ], + [ + 34.7900810241752, + -0.348499000422978 + ], + [ + 34.7909393311158, + -0.350154012735152 + ], + [ + 34.7962989806356, + -0.351779014158352 + ], + [ + 34.7963409423905, + -0.353913993008774 + ], + [ + 34.799270629772, + -0.35537099866552 + ], + [ + 34.8058509826727, + -0.356851994946864 + ], + [ + 34.8103294373075, + -0.35494199412092 + ], + [ + 34.811920165839, + -0.361380994683084 + ], + [ + 34.8153495787066, + -0.367538988586328 + ], + [ + 34.822010040142, + -0.375279009500654 + ], + [ + 34.8231887818121, + -0.388823986316104 + ], + [ + 34.8349685665297, + -0.390866011737571 + ], + [ + 34.8395805358943, + -0.39437299970995 + ], + [ + 34.8547096251044, + -0.393049001816052 + ], + [ + 34.8581581113839, + -0.393871993094201 + ], + [ + 34.8591995235789, + -0.396349012801326 + ], + [ + 34.8614196776106, + -0.397379994409412 + ], + [ + 34.864028930384, + -0.397596001585913 + ], + [ + 34.8667411800724, + -0.39584100258521 + ], + [ + 34.8800506592569, + -0.398986011932382 + ], + [ + 34.882549285679, + -0.39778301152296 + ], + [ + 34.8892898558066, + -0.404336005388276 + ], + [ + 34.8909606930819, + -0.403268993253184 + ], + [ + 34.8915901180646, + -0.399742990849798 + ], + [ + 34.8960609436001, + -0.398467004451745 + ], + [ + 34.9023208614997, + -0.393781006539643 + ], + [ + 34.9141883848943, + -0.389872014812091 + ], + [ + 34.9129295347827, + -0.397105991776296 + ], + [ + 34.9135284423775, + -0.406210988992565 + ], + [ + 34.9157791136356, + -0.408109992857834 + ], + [ + 34.9262809752293, + -0.406415999172597 + ], + [ + 34.9296684262365, + -0.415033996126971 + ], + [ + 34.9339103697409, + -0.415926993119768 + ], + [ + 34.9367218016871, + -0.414869993979198 + ], + [ + 34.9400482174569, + -0.417192012124249 + ], + [ + 34.9476394649912, + -0.418792009368722 + ], + [ + 34.9588203430649, + -0.419268012259248 + ], + [ + 34.964580535695, + -0.414997011740574 + ], + [ + 34.9682312010619, + -0.416606009222934 + ], + [ + 34.9742088316808, + -0.414806008342675 + ], + [ + 34.9797897336172, + -0.416812002955495 + ], + [ + 34.9864196774759, + -0.416004985686217 + ], + [ + 34.9888610840591, + -0.41196200288656 + ], + [ + 35.0020790099141, + -0.408315003142937 + ], + [ + 35.0098304748598, + -0.393557995512931 + ], + [ + 35.0165290828926, + -0.400588989310594 + ], + [ + 35.020030975015, + -0.407977998203072 + ], + [ + 35.0201301573808, + -0.413237988913887 + ], + [ + 35.0087089539056, + -0.422464013032612 + ], + [ + 34.984081268035, + -0.435945004299202 + ], + [ + 34.9849891660104, + -0.438230991460368 + ], + [ + 34.9612197872309, + -0.448563993126587 + ], + [ + 34.9566688537146, + -0.453774988592296 + ], + [ + 34.9549102782375, + -0.452564001210013 + ], + [ + 34.9318504333034, + -0.470339000564194 + ], + [ + 34.913478851067, + -0.478635013276366 + ], + [ + 34.9130516050926, + -0.481783002938289 + ], + [ + 34.903900146467, + -0.484786987561828 + ], + [ + 34.9031181333965, + -0.482174992638721 + ], + [ + 34.8889503477, + -0.486799001910646 + ], + [ + 34.8890991210648, + -0.488941997632412 + ], + [ + 34.8768196104796, + -0.494208991844917 + ], + [ + 34.8734207150136, + -0.492906988010304 + ], + [ + 34.8568992612835, + -0.506024003234788 + ], + [ + 34.844890594425, + -0.513670027293837 + ], + [ + 34.8381004330379, + -0.516238987710852 + ], + [ + 34.8374481199198, + -0.518221974371861 + ], + [ + 34.8363800047042, + -0.516646981491657 + ], + [ + 34.8180885311651, + -0.519816994747283 + ], + [ + 34.8184700009565, + -0.523418009515079 + ], + [ + 34.8106613157707, + -0.526766002395344 + ], + [ + 34.8081092832507, + -0.524913012938803 + ], + [ + 34.791728973295, + -0.533563017878239 + ], + [ + 34.7921409604513, + -0.536637008237299 + ], + [ + 34.7841415404687, + -0.54204797741411 + ], + [ + 34.7818489074306, + -0.539662003610068 + ], + [ + 34.7724494933551, + -0.543299019671276 + ], + [ + 34.7598190305008, + -0.551342010749138 + ], + [ + 34.7590904232552, + -0.554727018199034 + ], + [ + 34.7576103208967, + -0.552761972309827 + ], + [ + 34.7526588440351, + -0.557133019010684 + ], + [ + 34.7405891414782, + -0.55939000844221 + ], + [ + 34.7276000973846, + -0.556751013019578 + ], + [ + 34.7248611448276, + -0.55805397054927 + ], + [ + 34.7239189148249, + -0.562395990012802 + ], + [ + 34.7205581663754, + -0.563807010858834 + ], + [ + 34.7084693905658, + -0.56334197550079 + ], + [ + 34.7025794979385, + -0.57107502236711 + ], + [ + 34.7027893065484, + -0.574231028654441 + ], + [ + 34.7006187437383, + -0.578173995100854 + ], + [ + 34.6939506531515, + -0.585781991773064 + ], + [ + 34.6956901546825, + -0.588460028169261 + ], + [ + 34.6820793151083, + -0.605611026354928 + ], + [ + 34.6772689817705, + -0.607928991417576 + ], + [ + 34.6687088010492, + -0.608528018135938 + ], + [ + 34.6579513547959, + -0.608295023463937 + ], + [ + 34.6559104916652, + -0.606912016991926 + ], + [ + 34.6537895201871, + -0.609516978259065 + ], + [ + 34.6391716004, + -0.634738028207663 + ], + [ + 34.6383705139055, + -0.637398004544013 + ], + [ + 34.6397781372785, + -0.638979017954337 + ], + [ + 34.6343193051887, + -0.665705025246125 + ], + [ + 34.6205406186134, + -0.658393025710317 + ], + [ + 34.6131210324846, + -0.656325995932842 + ], + [ + 34.5904312130281, + -0.654632985651808 + ], + [ + 34.581218719295, + -0.654644012660536 + ], + [ + 34.5782089233828, + -0.661484003434846 + ], + [ + 34.5757789609572, + -0.663455009692252 + ], + [ + 34.5589103696885, + -0.668734014040843 + ], + [ + 34.5568008421623, + -0.676910996394468 + ], + [ + 34.5484886165825, + -0.689158976147545 + ], + [ + 34.546669006078, + -0.691166996901663 + ], + [ + 34.5432510376571, + -0.691955983595508 + ], + [ + 34.5411796568859, + -0.694706976771434 + ], + [ + 34.5331611633244, + -0.710030973139712 + ], + [ + 34.5330390927199, + -0.738547981046071 + ], + [ + 34.5311889646065, + -0.74408400068167 + ], + [ + 34.5243301391048, + -0.751465976566904 + ], + [ + 34.523319244073, + -0.755219996094406 + ], + [ + 34.4829406738704, + -0.790274977619968 + ], + [ + 34.481559753165, + -0.802295029175752 + ], + [ + 34.4693298338285, + -0.825273990933598 + ], + [ + 34.4647102352386, + -0.837071001531768 + ], + [ + 34.4626693723591, + -0.851777970927684 + ], + [ + 34.463150024297, + -0.86771601449314 + ], + [ + 34.439430236495, + -0.847472012212406 + ], + [ + 34.4319496151321, + -0.84376698732516 + ], + [ + 34.427528381048, + -0.838150978357247 + ], + [ + 34.4123306274858, + -0.830712020621397 + ], + [ + 34.403789520136, + -0.824156999657325 + ], + [ + 34.3919181822002, + -0.820127010642739 + ], + [ + 34.3891296386327, + -0.817822992967247 + ], + [ + 34.385238647331, + -0.822346985545578 + ], + [ + 34.3878517148165, + -0.828027010274183 + ], + [ + 34.3914909362143, + -0.830556988990309 + ], + [ + 34.3912887569553, + -0.832229972125801 + ], + [ + 34.3883514403493, + -0.835080981457174 + ], + [ + 34.3813896176497, + -0.847465992037647 + ], + [ + 34.3720703123242, + -0.849992990548269 + ], + [ + 34.3659706116052, + -0.855206012842816 + ], + [ + 34.3547210691039, + -0.853002011902929 + ], + [ + 34.3498115536549, + -0.850507974769047 + ], + [ + 34.3469085691785, + -0.850807011304048 + ], + [ + 34.3483581542548, + -0.857419014179398 + ], + [ + 34.3378715512162, + -0.857151985548191 + ], + [ + 34.3353996275051, + -0.861966013846852 + ], + [ + 34.3323783872629, + -0.864336014174842 + ], + [ + 34.3219299313395, + -0.866934001400902 + ], + [ + 34.3207893371094, + -0.869368016840148 + ], + [ + 34.3120613097195, + -0.862017989402179 + ], + [ + 34.2967910765639, + -0.841571986879134 + ], + [ + 34.2831993100554, + -0.829490006164039 + ], + [ + 34.2800292969425, + -0.827283978678287 + ], + [ + 34.2706985473092, + -0.826963007798812 + ], + [ + 34.255760192745, + -0.823557973140846 + ], + [ + 34.2516403194844, + -0.819657981545666 + ], + [ + 34.2500114439606, + -0.813898980606246 + ], + [ + 34.2352981564387, + -0.796841025397369 + ], + [ + 34.2297096252092, + -0.785510003755067 + ], + [ + 34.2168884277723, + -0.774847984475694 + ], + [ + 34.2137908932448, + -0.773727000078476 + ], + [ + 34.2022590634571, + -0.756389021871654 + ], + [ + 34.1978187557433, + -0.755196988621768 + ], + [ + 34.1961212158267, + -0.755307019070076 + ], + [ + 34.1938896178212, + -0.763637006417354 + ], + [ + 34.1874885558975, + -0.769863009458919 + ], + [ + 34.1785888671669, + -0.768164992467638 + ], + [ + 34.1735115048629, + -0.775547981240602 + ], + [ + 34.1637001037083, + -0.777657985893913 + ], + [ + 34.146179199202, + -0.78438401217971 + ], + [ + 34.1350784301819, + -0.790953993738234 + ], + [ + 34.1248512266192, + -0.794811010392652 + ], + [ + 34.1202583310897, + -0.787449002496724 + ], + [ + 34.1031608581006, + -0.787481009961492 + ], + [ + 34.0980987548552, + -0.789156019846152 + ], + [ + 34.0904998777627, + -0.793381989008938 + ], + [ + 34.0821304321186, + -0.805714011166162 + ], + [ + 34.0756301878191, + -0.807959020223218 + ], + [ + 34.0731887813577, + -0.805734992162836 + ], + [ + 34.0713806149705, + -0.799772024263116 + ], + [ + 34.070911407347, + -0.795912028064797 + ], + [ + 34.0761718746124, + -0.788185000296319 + ], + [ + 34.0791702268929, + -0.786366999075553 + ], + [ + 34.0781211854283, + -0.77315700064691 + ], + [ + 34.0756797788177, + -0.770870983553066 + ], + [ + 34.0727310176464, + -0.771928012775884 + ], + [ + 34.0707702635395, + -0.774968982057692 + ], + [ + 34.0678100581323, + -0.776280999365201 + ], + [ + 34.0652008054693, + -0.775638997715759 + ], + [ + 34.0682983398867, + -0.769587993507218 + ], + [ + 34.0722885124991, + -0.765767992213887 + ], + [ + 34.0734596248886, + -0.75789797296986 + ], + [ + 34.0711402893946, + -0.742384017023116 + ], + [ + 34.0682716362661, + -0.738946974186464 + ], + [ + 34.0656585691013, + -0.731875002658081 + ], + [ + 34.0589599610651, + -0.72876399809079 + ], + [ + 34.0561790467475, + -0.7291809921991 + ], + [ + 34.0511894221542, + -0.732782006194536 + ], + [ + 34.0431709283489, + -0.734619022046682 + ], + [ + 34.0432586663373, + -0.739524007362461 + ], + [ + 34.0412483211126, + -0.742564022830797 + ], + [ + 34.0390701292639, + -0.743134022115547 + ], + [ + 34.0375518798052, + -0.741271973341088 + ], + [ + 34.0341415399172, + -0.742411017949055 + ], + [ + 34.0290107721199, + -0.740079999563629 + ], + [ + 34.0344581600094, + -0.738240004025029 + ], + [ + 34.0370407099569, + -0.735108971714719 + ], + [ + 34.0365409849073, + -0.729981005287054 + ], + [ + 34.0402297971026, + -0.730431974673244 + ], + [ + 34.0455207822751, + -0.72615200287414 + ], + [ + 34.0493698114945, + -0.72751700888947 + ], + [ + 34.0549507137005, + -0.722702980450744 + ], + [ + 34.057571411253, + -0.717764974292993 + ], + [ + 34.0569000236933, + -0.714806020454154 + ], + [ + 34.04867935143, + -0.710771978655859 + ], + [ + 34.0502204887992, + -0.701833010373213 + ], + [ + 34.0476188661232, + -0.696263015386548 + ], + [ + 34.0508689874669, + -0.688364028892766 + ], + [ + 34.0486183167265, + -0.685244023969903 + ], + [ + 34.0509605408343, + -0.682855010357811 + ], + [ + 34.0527191162991, + -0.682901025140196 + ], + [ + 34.05387115413, + -0.67493999052484 + ], + [ + 34.0474395745586, + -0.666799008970179 + ], + [ + 34.0528297425442, + -0.653914988114916 + ], + [ + 34.0501098632817, + -0.649474978372914 + ], + [ + 34.0444602961453, + -0.650641978499759 + ], + [ + 34.0443992613612, + -0.648643016910792 + ], + [ + 34.0485992431052, + -0.645349979282076 + ], + [ + 34.050479888771, + -0.645856023060513 + ], + [ + 34.0521888733874, + -0.63608497387884 + ], + [ + 34.0674095147023, + -0.634290993351509 + ], + [ + 34.0785484311953, + -0.62570297740823 + ], + [ + 34.088008880649, + -0.611697971865403 + ], + [ + 34.0916709895808, + -0.610395014213498 + ], + [ + 34.0937614438246, + -0.605754018462416 + ], + [ + 34.0918388365711, + -0.600144982953824 + ], + [ + 34.0879898064917, + -0.596826971219836 + ], + [ + 34.0745315544818, + -0.597922981413904 + ], + [ + 34.0739402768513, + -0.595815003636328 + ], + [ + 34.0774497980607, + -0.5891940001894 + ], + [ + 34.0760116570081, + -0.586480021509032 + ], + [ + 34.0684509274621, + -0.583840012666918 + ], + [ + 34.0651397701482, + -0.576095999009033 + ], + [ + 34.0606918334776, + -0.572252988952719 + ], + [ + 34.0639114377092, + -0.567799985398557 + ], + [ + 34.0726585385331, + -0.56365597311954 + ], + [ + 34.0796394349264, + -0.56975299151503 + ], + [ + 34.083549499657, + -0.571188987051289 + ], + [ + 34.0907783504521, + -0.570609986860741 + ], + [ + 34.1054115294824, + -0.559733987424752 + ], + [ + 34.107849120367, + -0.552416026847594 + ], + [ + 34.1064682007827, + -0.547809005070191 + ], + [ + 34.1071090695246, + -0.543847978198801 + ], + [ + 34.1143112175524, + -0.536817014212047 + ], + [ + 34.1167907714685, + -0.539178014319734 + ], + [ + 34.1197319029813, + -0.550728977437468 + ], + [ + 34.1235885614383, + -0.552347004589108 + ], + [ + 34.1289405823148, + -0.551360011161143 + ], + [ + 34.1317291253374, + -0.552417993454294 + ], + [ + 34.1394081113554, + -0.548582017566871 + ], + [ + 34.1423187250372, + -0.544880986087096 + ], + [ + 34.144321441609, + -0.545468986685797 + ], + [ + 34.1475105283877, + -0.543361008615654 + ], + [ + 34.152400970355, + -0.543421984053153 + ], + [ + 34.156341552752, + -0.545683026385006 + ], + [ + 34.1595382683595, + -0.540082991103644 + ], + [ + 34.1603202817098, + -0.515251994711433 + ], + [ + 34.1581497192617, + -0.504496992292562 + ], + [ + 34.1604881281574, + -0.499439001384675 + ], + [ + 34.1609497063407, + -0.489695996220955 + ], + [ + 34.163379669048, + -0.483860999881784 + ], + [ + 34.1732902527968, + -0.470301002784427 + ], + [ + 34.1869506834279, + -0.460844010098408 + ], + [ + 34.1926994320973, + -0.454766006007449 + ], + [ + 34.2032890318822, + -0.4207769933714 + ], + [ + 34.2061386102449, + -0.424387008706865 + ], + [ + 34.2087097168159, + -0.431968003499942 + ], + [ + 34.2182998652968, + -0.438279002810108 + ], + [ + 34.2254219053305, + -0.436731010872808 + ], + [ + 34.2365684502905, + -0.441911995742642 + ], + [ + 34.2524681086205, + -0.436401009552138 + ], + [ + 34.2529907221092, + -0.44451698703856 + ], + [ + 34.2594184869813, + -0.446025997923519 + ], + [ + 34.2629013060622, + -0.452048987886204 + ], + [ + 34.2688789366629, + -0.45137000094824 + ], + [ + 34.2703590387349, + -0.455511004157731 + ], + [ + 34.2741394036691, + -0.458741009483158 + ], + [ + 34.280330657895, + -0.456106007033767 + ], + [ + 34.2824592583154, + -0.45748299434674 + ], + [ + 34.2834892273559, + -0.459463000604624 + ], + [ + 34.2800407402638, + -0.460276991155232 + ], + [ + 34.2789001466036, + -0.463705003632667 + ], + [ + 34.283550261735, + -0.469538987016963 + ], + [ + 34.2834396362384, + -0.473872006067547 + ], + [ + 34.2976913450659, + -0.475064009430141 + ], + [ + 34.3135986329124, + -0.480857998356647 + ], + [ + 34.3105201715903, + -0.474254995573612 + ], + [ + 34.3003311153427, + -0.467819005557572 + ], + [ + 34.3049507136834, + -0.463645995390642 + ], + [ + 34.3085594172338, + -0.463880002625653 + ], + [ + 34.315311431233, + -0.459791988851047 + ], + [ + 34.317020415631, + -0.451388002025403 + ], + [ + 34.3193092341998, + -0.450635999371566 + ], + [ + 34.3215293879414, + -0.453613012918267 + ], + [ + 34.3230705258005, + -0.463761985581871 + ], + [ + 34.3267593379654, + -0.468165010196024 + ], + [ + 34.3342285152181, + -0.471148014610527 + ], + [ + 34.3357505794744, + -0.473010987297488 + ], + [ + 34.3354301451036, + -0.4886249906958 + ], + [ + 34.3418083185914, + -0.493146986526297 + ], + [ + 34.3470687859187, + -0.500118971602229 + ], + [ + 34.3529014582467, + -0.493514001737942 + ], + [ + 34.3521194456529, + -0.483509987777601 + ], + [ + 34.366508483601, + -0.479157001339806 + ], + [ + 34.3675308224165, + -0.476034999154943 + ], + [ + 34.3668594355767, + -0.464170009646362 + ], + [ + 34.3624382015901, + -0.459681987671771 + ], + [ + 34.3632583617092, + -0.456544012204122 + ], + [ + 34.3678703301685, + -0.453404993492671 + ], + [ + 34.3700981136261, + -0.449063987204993 + ], + [ + 34.3764991752945, + -0.456070005798174 + ], + [ + 34.3916893006763, + -0.458047985911118 + ], + [ + 34.401031494224, + -0.456772000208509 + ], + [ + 34.4029693602712, + -0.467581987417169 + ], + [ + 34.4088096617819, + -0.469994991905746 + ], + [ + 34.4044914238342, + -0.472618013842378 + ], + [ + 34.4040718075724, + -0.475937009620412 + ], + [ + 34.4056816096003, + -0.479736000529644 + ], + [ + 34.4168281556415, + -0.486526012354353 + ], + [ + 34.4182815549457, + -0.498194993204119 + ], + [ + 34.4238395691904, + -0.497153014227329 + ], + [ + 34.4309196471564, + -0.492648006171129 + ], + [ + 34.4334793087006, + -0.493081003646959 + ], + [ + 34.4351005552834, + -0.49733999363866 + ], + [ + 34.4360809325886, + -0.50118499989729 + ], + [ + 34.434089660598, + -0.510565996614679 + ], + [ + 34.424320220237, + -0.509644985527284 + ], + [ + 34.4156608576732, + -0.522058010291885 + ], + [ + 34.4154510495269, + -0.533038020703262 + ], + [ + 34.4202499384653, + -0.538254976419576 + ], + [ + 34.4232292167982, + -0.537786007495437 + ], + [ + 34.43722915608, + -0.530489981734109 + ], + [ + 34.4576988221713, + -0.52347499174317 + ], + [ + 34.4714088432659, + -0.515331984031223 + ], + [ + 34.4840393059932, + -0.510987997642402 + ], + [ + 34.486560821311, + -0.508825003996875 + ], + [ + 34.4892196652765, + -0.502313018317028 + ], + [ + 34.4898605347394, + -0.496461004262265 + ], + [ + 34.4885215756286, + -0.494433999326097 + ], + [ + 34.4913215631515, + -0.492246002432743 + ], + [ + 34.4953498838338, + -0.493075997261786 + ], + [ + 34.4951210022943, + -0.491421014145014 + ], + [ + 34.4967803952732, + -0.490705997263222 + ], + [ + 34.5017700192491, + -0.491519004300737 + ], + [ + 34.5097999569706, + -0.474269002690856 + ], + [ + 34.4976615900979, + -0.461302995572214 + ], + [ + 34.4897499083798, + -0.457078993406483 + ], + [ + 34.4789581294039, + -0.445430994406663 + ], + [ + 34.4715805049799, + -0.441020995268441 + ], + [ + 34.466499328408, + -0.432491988560971 + ], + [ + 34.4506492612813, + -0.415082007832338 + ], + [ + 34.4478988645194, + -0.403017997841618 + ], + [ + 34.4443511956121, + -0.398584991931709 + ], + [ + 34.4436111447052, + -0.39542201172729 + ], + [ + 34.4468612671222, + -0.391503989593715 + ], + [ + 34.4472808838166, + -0.385053992888977 + ], + [ + 34.4562110896362, + -0.367188007367484 + ], + [ + 34.4594993588715, + -0.354263008206138 + ], + [ + 34.4777793883361, + -0.346300005779269 + ], + [ + 34.4901199339982, + -0.34630900690649 + ], + [ + 34.4986915589147, + -0.339442015196204 + ], + [ + 34.5182800293198, + -0.333279013679969 + ], + [ + 34.5369796747646, + -0.328709006646547 + ], + [ + 34.5505218502734, + -0.328009992847082 + ], + [ + 34.5661201477624, + -0.330810994514755 + ], + [ + 34.5712890621413, + -0.336508989801025 + ], + [ + 34.5864715573582, + -0.3466799859601 + ], + [ + 34.5956306454186, + -0.348459988858955 + ], + [ + 34.6072006223563, + -0.348713010832994 + ], + [ + 34.6107215880723, + -0.343385011144085 + ], + [ + 34.6141014098723, + -0.341919005308692 + ], + [ + 34.6267089844781, + -0.340615987849872 + ], + [ + 34.6286888119123, + -0.341156005802005 + ], + [ + 34.6325187675872, + -0.350391001042175 + ], + [ + 34.6447486877118, + -0.352214009250388 + ], + [ + 34.6584892268869, + -0.356934011228453 + ], + [ + 34.6620483394615, + -0.356644988285097 + ], + [ + 34.6638908386978, + -0.354889989433075 + ], + [ + 34.6727981561785, + -0.357482999764506 + ], + [ + 34.6768302916616, + -0.356974989931195 + ], + [ + 34.6861915588006, + -0.352155000077462 + ], + [ + 34.6959991451256, + -0.354178995010005 + ], + [ + 34.6998481746875, + -0.353742986990006 + ], + [ + 34.7048492426555, + -0.350946993123794 + ], + [ + 34.7092781065951, + -0.354761988308264 + ], + [ + 34.7306289671357, + -0.356649011863722 + ], + [ + 34.7413291932561, + -0.352975011359628 + ], + [ + 34.7559013363737, + -0.352964014576499 + ], + [ + 34.7630996703498, + -0.336782008282447 + ], + [ + 34.763130187823, + -0.327603996050633 + ], + [ + 34.7587394710526, + -0.322838008741381 + ], + [ + 34.7507514951339, + -0.320596009467681 + ], + [ + 34.7471885678729, + -0.312557012604734 + ], + [ + 34.7473487852729, + -0.308162987982089 + ], + [ + 34.7454299924869, + -0.304329008317231 + ], + [ + 34.7516098015184, + -0.300310999323709 + ], + [ + 34.7524299622871, + -0.293628990558029 + ], + [ + 34.7575492852908, + -0.300999999306465 + ], + [ + 34.7566299433373, + -0.308676005154477 + ], + [ + 34.7582206719739, + -0.31252101087114 + ] + ] + ], + [ + [ + [ + 34.3994903560935, + -0.254267991244887 + ], + [ + 34.3970108032719, + -0.253908008974494 + ], + [ + 34.4013214111923, + -0.251022995196485 + ], + [ + 34.4017791743855, + -0.252530992768988 + ], + [ + 34.3994903560935, + -0.254267991244887 + ] + ] + ], + [ + [ + [ + 34.1367187496645, + -0.24409900678151 + ], + [ + 34.1348915094622, + -0.246243000392158 + ], + [ + 34.1344490048806, + -0.241639003507447 + ], + [ + 34.1362686151219, + -0.241992995808608 + ], + [ + 34.1367187496645, + -0.24409900678151 + ] + ] + ], + [ + [ + [ + 34.1424903865964, + -0.226278006942752 + ], + [ + 34.1440010069603, + -0.227889001630092 + ], + [ + 34.1479988097435, + -0.227724999554172 + ], + [ + 34.1474494933468, + -0.233108998002222 + ], + [ + 34.1487312313635, + -0.235117003691857 + ], + [ + 34.1431007386267, + -0.241015002285198 + ], + [ + 34.1433792107537, + -0.245220005740591 + ], + [ + 34.1419181823993, + -0.245383993358565 + ], + [ + 34.1405982970632, + -0.243122995248227 + ], + [ + 34.141139983766, + -0.23482699730886 + ], + [ + 34.1335601804736, + -0.231389999441659 + ], + [ + 34.1395797731, + -0.230031997591843 + ], + [ + 34.1402587885971, + -0.227228999215948 + ], + [ + 34.1424903865964, + -0.226278006942752 + ] + ] + ], + [ + [ + [ + 34.1140289307814, + -0.214855000728845 + ], + [ + 34.1142387386113, + -0.221377998995979 + ], + [ + 34.1170005791669, + -0.224905997762796 + ], + [ + 34.114749908376, + -0.225765005461102 + ], + [ + 34.1110191342269, + -0.223196998939716 + ], + [ + 34.1095314022483, + -0.220057994860915 + ], + [ + 34.1094894406354, + -0.21593299523273 + ], + [ + 34.1140289307814, + -0.214855000728845 + ] + ] + ], + [ + [ + [ + 34.4601287838158, + -0.215441004048315 + ], + [ + 34.4576797481487, + -0.217079997201956 + ], + [ + 34.4563102723182, + -0.215496004321451 + ], + [ + 34.4568099972457, + -0.213398993618589 + ], + [ + 34.4675712579274, + -0.2100050007577 + ], + [ + 34.4601287838158, + -0.215441004048315 + ] + ] + ], + [ + [ + [ + 34.518421173039, + -0.206698000877984 + ], + [ + 34.5200386045181, + -0.20788300095285 + ], + [ + 34.5236816405419, + -0.206997007583868 + ], + [ + 34.5239410394751, + -0.208343998064179 + ], + [ + 34.5185508722993, + -0.213635995966025 + ], + [ + 34.5161590576878, + -0.214614004630746 + ], + [ + 34.5155715941595, + -0.211816997059059 + ], + [ + 34.5119209287706, + -0.2104970066698 + ], + [ + 34.5082283014411, + -0.216396004245904 + ], + [ + 34.5004692075734, + -0.213556006777153 + ], + [ + 34.4984397885346, + -0.218287006036259 + ], + [ + 34.4964790342599, + -0.217318997325647 + ], + [ + 34.4956092831303, + -0.212118000349885 + ], + [ + 34.502929687432, + -0.200975001600127 + ], + [ + 34.509170532324, + -0.196326002751571 + ], + [ + 34.5174217224807, + -0.194516003818882 + ], + [ + 34.5191917419178, + -0.196198002003704 + ], + [ + 34.5162200921552, + -0.202952996325382 + ], + [ + 34.518421173039, + -0.206698000877984 + ] + ] + ], + [ + [ + [ + 34.0568504333938, + -0.192895994180268 + ], + [ + 34.055400848418, + -0.193039000564602 + ], + [ + 34.0549697873727, + -0.191231996185407 + ], + [ + 34.0574417114117, + -0.189449995967354 + ], + [ + 34.0568504333938, + -0.192895994180268 + ] + ] + ], + [ + [ + [ + 34.5378608703482, + -0.183923006587942 + ], + [ + 34.5444297783191, + -0.186717003994445 + ], + [ + 34.5436210627033, + -0.18843500334416 + ], + [ + 34.5395889277135, + -0.189955994372752 + ], + [ + 34.5374298094948, + -0.188463002717202 + ], + [ + 34.536289215183, + -0.186029002680658 + ], + [ + 34.5378608703482, + -0.183923006587942 + ] + ] + ], + [ + [ + [ + 34.5419311520741, + -0.177554995459931 + ], + [ + 34.5372085568702, + -0.177201002756337 + ], + [ + 34.5354614251704, + -0.175465003046841 + ], + [ + 34.5363998405701, + -0.1735209974197 + ], + [ + 34.5393600456777, + -0.172552004755814 + ], + [ + 34.5441207884934, + -0.174659997249441 + ], + [ + 34.5419311520741, + -0.177554995459931 + ] + ] + ], + [ + [ + [ + 34.5511016838423, + -0.182683006154941 + ], + [ + 34.5497207642557, + -0.183956995819815 + ], + [ + 34.5482292169827, + -0.18155199332737 + ], + [ + 34.5453987115703, + -0.172923997004285 + ], + [ + 34.5487403871132, + -0.171819999859916 + ], + [ + 34.5495491024781, + -0.173067003763431 + ], + [ + 34.5491409300549, + -0.176477000202428 + ], + [ + 34.5524291988164, + -0.179000005227063 + ], + [ + 34.5511016838423, + -0.182683006154941 + ] + ] + ], + [ + [ + [ + 34.6083106991855, + -0.160131007388101 + ], + [ + 34.6149406427355, + -0.159730002525364 + ], + [ + 34.608940123785, + -0.166026994763317 + ], + [ + 34.6068801877634, + -0.165728003413682 + ], + [ + 34.6035194389719, + -0.169064999321015 + ], + [ + 34.6028289792516, + -0.16241699502844 + ], + [ + 34.6003990167273, + -0.160761997604623 + ], + [ + 34.6007881163203, + -0.158963993230258 + ], + [ + 34.6083106991855, + -0.160131007388101 + ] + ] + ], + [ + [ + [ + 34.045108794793, + -0.135089994200292 + ], + [ + 34.043380737124, + -0.136049002725151 + ], + [ + 34.0413894648103, + -0.133091002945988 + ], + [ + 34.0467910759328, + -0.131053999547496 + ], + [ + 34.0495986939957, + -0.132810995049119 + ], + [ + 34.045108794793, + -0.135089994200292 + ] + ] + ], + [ + [ + [ + 34.0102806089366, + -0.128078997777673 + ], + [ + 34.0132789612483, + -0.128405005153257 + ], + [ + 34.0161590573009, + -0.126378998698566 + ], + [ + 34.0177001950656, + -0.130412996241417 + ], + [ + 34.0238304137994, + -0.133670002439227 + ], + [ + 34.0317001341465, + -0.128378004044975 + ], + [ + 34.0367202754878, + -0.126586005443822 + ], + [ + 34.0388107294861, + -0.127607003204381 + ], + [ + 34.0369911191701, + -0.130215004542825 + ], + [ + 34.0267601010053, + -0.135308995968923 + ], + [ + 34.0240211487638, + -0.141975000926457 + ], + [ + 34.0196914668423, + -0.140771999875237 + ], + [ + 34.0166702266165, + -0.143105999013996 + ], + [ + 34.0095405575111, + -0.141286999235816 + ], + [ + 34.00308990427, + -0.136683002556363 + ], + [ + 34.0004692078828, + -0.137262002154141 + ], + [ + 33.9991111751425, + -0.139939993995134 + ], + [ + 33.9956207269556, + -0.134287998315211 + ], + [ + 33.9916610713048, + -0.133346006817162 + ], + [ + 33.9866561887919, + -0.136118993785121 + ], + [ + 33.9867858880363, + -0.127811998463105 + ], + [ + 33.9913787837895, + -0.126678004990381 + ], + [ + 33.9958686828561, + -0.121123001650573 + ], + [ + 34.0060005185283, + -0.124252997846045 + ], + [ + 34.0102806089366, + -0.128078997777673 + ] + ] + ], + [ + [ + [ + 34.0999984741821, + -0.135629996806123 + ], + [ + 34.0973281854461, + -0.13674199632561 + ], + [ + 34.0929489136276, + -0.134572998173591 + ], + [ + 34.0923995964831, + -0.129841000467649 + ], + [ + 34.0875015256742, + -0.126992002052066 + ], + [ + 34.0911407468937, + -0.124214001415072 + ], + [ + 34.0930213922766, + -0.120668001512562 + ], + [ + 34.097450255924, + -0.122630000202121 + ], + [ + 34.1001281732362, + -0.127587005499911 + ], + [ + 34.0999984741821, + -0.135629996806123 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "projectId": 2274, + "projectName": "PEPFAR Kenya: Homa Bay", + "projectStatus": "ARCHIVED" + }, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} diff --git a/tests/api/helpers/test_files/self_intersecting_aoi.json b/tests/api/helpers/test_files/self_intersecting_aoi.json new file mode 100644 index 0000000000..0f2081fb55 --- /dev/null +++ b/tests/api/helpers/test_files/self_intersecting_aoi.json @@ -0,0 +1,121 @@ +{ + "areaOfInterest": { + "type": "FeatureCollection", + "features": [ + { + "id": "c80597d7c1465de9a626abb42c8ce3e9", + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [ + 83.90260192613022, + 28.00981666293339 + ], + [ + 83.9463765105823, + 28.011172694480237 + ], + [ + 83.93869675892398, + 27.96980605451823 + ], + [ + 83.97709551721579, + 27.990152287354846 + ], + [ + 83.90260192613022, + 28.00981666293339 + ] + ] + ], + "type": "Polygon" + } + } + ] + }, + "clipToAoi": true, + "grid": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "x": 1501, + "y": 1189, + "zoom": 11, + "isSquare": true + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 83.84765624665238, + 27.83907609375386 + ], + [ + 84.02343749664536, + 27.83907609375386 + ], + [ + 84.02343749664536, + 27.994401410017474 + ], + [ + 83.84765624665238, + 27.994401410017474 + ], + [ + 83.84765624665238, + 27.83907609375386 + ] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "x": 1501, + "y": 1190, + "zoom": 11, + "isSquare": true + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 83.84765624665238, + 27.994401410017474 + ], + [ + 84.02343749664536, + 27.994401410017474 + ], + [ + 84.02343749664536, + 28.14950321051117 + ], + [ + 83.84765624665238, + 28.14950321051117 + ], + [ + 83.84765624665238, + 27.994401410017474 + ] + ] + ] + ] + } + } + ] + } + } diff --git a/tests/api/helpers/test_files/split_task.json b/tests/api/helpers/test_files/split_task.json new file mode 100644 index 0000000000..047218e27a --- /dev/null +++ b/tests/api/helpers/test_files/split_task.json @@ -0,0 +1,154 @@ +[ + { + "geometry": { + "coordinates": [ + [ + [ + [ + -2.4609375, + 54.876606647 + ], + [ + -2.373046875, + 54.876606647 + ], + [ + -2.373046875, + 54.927141858 + ], + [ + -2.4609375, + 54.927141858 + ], + [ + -2.4609375, + 54.876606647 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 2020, + "y": 2798, + "zoom": 12 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + -2.4609375, + 54.927141858 + ], + [ + -2.373046875, + 54.927141858 + ], + [ + -2.373046875, + 54.977613664 + ], + [ + -2.4609375, + 54.977613664 + ], + [ + -2.4609375, + 54.927141858 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 2020, + "y": 2799, + "zoom": 12 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + -2.373046875, + 54.876606647 + ], + [ + -2.28515625, + 54.876606647 + ], + [ + -2.28515625, + 54.927141858 + ], + [ + -2.373046875, + 54.927141858 + ], + [ + -2.373046875, + 54.876606647 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 2021, + "y": 2798, + "zoom": 12 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + [ + -2.373046875, + 54.927141858 + ], + [ + -2.28515625, + 54.927141858 + ], + [ + -2.28515625, + 54.977613664 + ], + [ + -2.373046875, + 54.977613664 + ], + [ + -2.373046875, + 54.927141858 + ] + ] + ] + ], + "type": "MultiPolygon" + }, + "properties": { + "isSquare": true, + "x": 2021, + "y": 2799, + "zoom": 12 + }, + "type": "Feature" + } +] diff --git a/tests/api/helpers/test_files/splittable_task.json b/tests/api/helpers/test_files/splittable_task.json new file mode 100644 index 0000000000..2c488286c6 --- /dev/null +++ b/tests/api/helpers/test_files/splittable_task.json @@ -0,0 +1,38 @@ +{ + "type":"Feature", + "geometry":{ + "type":"MultiPolygon", + "coordinates":[ + [ + [ + [ + -2.4609374995591673, + 54.8766066473153 + ], + [ + -2.4609374995591673, + 54.97761366390182 + ], + [ + -2.2851562495906625, + 54.97761366390182 + ], + [ + -2.2851562495906625, + 54.8766066473153 + ], + [ + -2.4609374995591673, + 54.8766066473153 + ] + ] + ] + ] + }, + "properties":{ + "x":1010, + "y":1399, + "zoom":11, + "isSquare":true + } +} diff --git a/tests/api/helpers/test_files/tasks_from_aoi_features.json b/tests/api/helpers/test_files/tasks_from_aoi_features.json new file mode 100644 index 0000000000..4adfd3db51 --- /dev/null +++ b/tests/api/helpers/test_files/tasks_from_aoi_features.json @@ -0,0 +1,42 @@ +{ + "features":[ + { + "geometry":{ + "coordinates":[ + [ + [ + [ + 1275275.3796814454, + 2828783.0982895694 + ], + [ + 1275200.6907704484, + 2828781.542271093 + ], + [ + 1275016.6455451597, + 2828781.542271093 + ], + [ + 1275275.3796814454, + 2828783.0982895694 + ] + ] + ] + ], + "type":"MultiPolygon" + }, + "properties":{ + "isSquare": false, + "x": null, + "y": null, + "zoom": null, + "extra_properties":{ + "foo":"bar" + } + }, + "type":"Feature" + } + ], + "type":"FeatureCollection" +} diff --git a/tests/api/helpers/test_files/test_aoi.json b/tests/api/helpers/test_files/test_aoi.json new file mode 100644 index 0000000000..588623da3c --- /dev/null +++ b/tests/api/helpers/test_files/test_aoi.json @@ -0,0 +1,26 @@ +{ + "features": [ + { + "type": "Feature", + "geometry": { + "coordinates": [ + [ + [ + [-4.0237, 56.0904], + [-3.9111, 56.1715], + [-3.8122, 56.098], + [-4.0237, 56.0904] + ] + ] + ], + "properties": { + "x": 2402, + "y": 1736, + "zoom": 12 + }, + "type": "MultiPolygon" + } + } + ], + "type": "FeatureCollection" +} diff --git a/tests/api/helpers/test_files/test_arbitrary.json b/tests/api/helpers/test_files/test_arbitrary.json new file mode 100644 index 0000000000..5a71106fe4 --- /dev/null +++ b/tests/api/helpers/test_files/test_arbitrary.json @@ -0,0 +1,36 @@ +{ + "areaOfInterest": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 1275275.3796814454, + 2828783.0982895694 + ], + [ + 1275200.6907704484, + 2828781.542271093 + ], + [ + 1275016.6455451597, + 2828781.542271093 + ], + [ + 1275275.3796814454, + 2828783.0982895694 + ] + ] + ] + }, + "properties": { + "foo": "bar" + } + } + ] + } +} diff --git a/tests/api/helpers/test_files/test_grid.json b/tests/api/helpers/test_files/test_grid.json new file mode 100644 index 0000000000..a178ab6298 --- /dev/null +++ b/tests/api/helpers/test_files/test_grid.json @@ -0,0 +1,3576 @@ +{ + "areaOfInterest": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 1275275.3796814454, + 2828783.0982895694 + ], + [ + 1275200.6907704484, + 2828781.542271093 + ], + [ + 1275016.6455451597, + 2828781.542271093 + ], + [ + 1275087.6232993654, + 2829087.2903841813 + ], + [ + 1275275.3796814454, + 2829087.2903841813 + ], + [ + 1275275.3796814454, + 2828783.0982895694 + ] + ] + ] + }, + "properties": { + "x": 69706, + "y": 74788, + "zoom": 17 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 1275581.1277945302, + 2829306.621247852 + ], + [ + 1275827.894530152, + 2829303.193932765 + ], + [ + 1275872.7042755112, + 2829087.2903841813 + ], + [ + 1275581.1277945302, + 2829087.2903841813 + ], + [ + 1275581.1277945302, + 2829306.621247852 + ] + ] + ] + }, + "properties": { + "x": 69708, + "y": 74789, + "zoom": 17 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 1275886.8759076186, + 2829019.008869979 + ], + [ + 1275932.9954440442, + 2828796.798620377 + ], + [ + 1275886.8759076186, + 2828795.8377961316 + ], + [ + 1275886.8759076186, + 2829019.008869979 + ] + ] + ] + }, + "properties": { + "x": 69709, + "y": 74788, + "zoom": 17 + } + } + ] + }, + "clipToAoi": false, + "grid": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1274969.631568361, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1274969.631568361, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2828934.4163276367 + ], + [ + 1274969.631568361, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1274969.631568361, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2829010.853355907 + ], + [ + 1274969.631568361, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1274969.631568361, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2829087.290384181 + ], + [ + 1274969.631568361, + 2829163.727412451 + ], + [ + 1275046.0685966313, + 2829163.727412451 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1274969.631568361, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2829163.727412451 + ], + [ + 1274969.631568361, + 2829240.1644407213 + ], + [ + 1275046.0685966313, + 2829240.1644407213 + ], + [ + 1275046.0685966313, + 2829163.727412451 + ], + [ + 1274969.631568361, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1274969.631568361, + 2829240.1644407213 + ], + [ + 1274969.631568361, + 2829316.601468995 + ], + [ + 1275046.0685966313, + 2829316.601468995 + ], + [ + 1275046.0685966313, + 2829240.1644407213 + ], + [ + 1274969.631568361, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278824, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275046.0685966313, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275046.0685966313, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275046.0685966313, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275046.0685966313, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275046.0685966313, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829163.727412451 + ], + [ + 1275046.0685966313, + 2829240.1644407213 + ], + [ + 1275122.5056249015, + 2829240.1644407213 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275046.0685966313, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275046.0685966313, + 2829240.1644407213 + ], + [ + 1275046.0685966313, + 2829316.601468995 + ], + [ + 1275122.5056249015, + 2829316.601468995 + ], + [ + 1275122.5056249015, + 2829240.1644407213 + ], + [ + 1275046.0685966313, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278825, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275122.5056249015, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275122.5056249015, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275122.5056249015, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275122.5056249015, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275122.5056249015, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829240.1644407213 + ], + [ + 1275198.9426531754, + 2829240.1644407213 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275122.5056249015, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275122.5056249015, + 2829240.1644407213 + ], + [ + 1275122.5056249015, + 2829316.601468995 + ], + [ + 1275198.9426531754, + 2829316.601468995 + ], + [ + 1275198.9426531754, + 2829240.1644407213 + ], + [ + 1275122.5056249015, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278826, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828781.5422710925 + ], + [ + 1275198.9426531754, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275198.9426531754, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275198.9426531754, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275198.9426531754, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275198.9426531754, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829240.1644407213 + ], + [ + 1275275.3796814457, + 2829240.1644407213 + ], + [ + 1275275.3796814457, + 2829163.727412451 + ], + [ + 1275198.9426531754, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275198.9426531754, + 2829240.1644407213 + ], + [ + 1275198.9426531754, + 2829316.601468995 + ], + [ + 1275275.3796814457, + 2829316.601468995 + ], + [ + 1275275.3796814457, + 2829240.1644407213 + ], + [ + 1275198.9426531754, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278827, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2828781.5422710925 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275351.8167097159, + 2828857.9792993665 + ], + [ + 1275351.8167097159, + 2828781.5422710925 + ], + [ + 1275275.3796814457, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275351.8167097159, + 2828934.4163276367 + ], + [ + 1275351.8167097159, + 2828857.9792993665 + ], + [ + 1275275.3796814457, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275351.8167097159, + 2829010.853355907 + ], + [ + 1275351.8167097159, + 2828934.4163276367 + ], + [ + 1275275.3796814457, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275351.8167097159, + 2829087.290384181 + ], + [ + 1275351.8167097159, + 2829010.853355907 + ], + [ + 1275275.3796814457, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829163.727412451 + ], + [ + 1275351.8167097159, + 2829163.727412451 + ], + [ + 1275351.8167097159, + 2829087.290384181 + ], + [ + 1275275.3796814457, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829240.1644407213 + ], + [ + 1275351.8167097159, + 2829240.1644407213 + ], + [ + 1275351.8167097159, + 2829163.727412451 + ], + [ + 1275275.3796814457, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275275.3796814457, + 2829240.1644407213 + ], + [ + 1275275.3796814457, + 2829316.601468995 + ], + [ + 1275351.8167097159, + 2829316.601468995 + ], + [ + 1275351.8167097159, + 2829240.1644407213 + ], + [ + 1275275.3796814457, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278828, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2828781.5422710925 + ], + [ + 1275351.8167097159, + 2828857.9792993665 + ], + [ + 1275428.2537379898, + 2828857.9792993665 + ], + [ + 1275428.2537379898, + 2828781.5422710925 + ], + [ + 1275351.8167097159, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2828857.9792993665 + ], + [ + 1275351.8167097159, + 2828934.4163276367 + ], + [ + 1275428.2537379898, + 2828934.4163276367 + ], + [ + 1275428.2537379898, + 2828857.9792993665 + ], + [ + 1275351.8167097159, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2828934.4163276367 + ], + [ + 1275351.8167097159, + 2829010.853355907 + ], + [ + 1275428.2537379898, + 2829010.853355907 + ], + [ + 1275428.2537379898, + 2828934.4163276367 + ], + [ + 1275351.8167097159, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2829010.853355907 + ], + [ + 1275351.8167097159, + 2829087.290384181 + ], + [ + 1275428.2537379898, + 2829087.290384181 + ], + [ + 1275428.2537379898, + 2829010.853355907 + ], + [ + 1275351.8167097159, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2829087.290384181 + ], + [ + 1275351.8167097159, + 2829163.727412451 + ], + [ + 1275428.2537379898, + 2829163.727412451 + ], + [ + 1275428.2537379898, + 2829087.290384181 + ], + [ + 1275351.8167097159, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2829163.727412451 + ], + [ + 1275351.8167097159, + 2829240.1644407213 + ], + [ + 1275428.2537379898, + 2829240.1644407213 + ], + [ + 1275428.2537379898, + 2829163.727412451 + ], + [ + 1275351.8167097159, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275351.8167097159, + 2829240.1644407213 + ], + [ + 1275351.8167097159, + 2829316.601468995 + ], + [ + 1275428.2537379898, + 2829316.601468995 + ], + [ + 1275428.2537379898, + 2829240.1644407213 + ], + [ + 1275351.8167097159, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278829, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2828781.5422710925 + ], + [ + 1275428.2537379898, + 2828857.9792993665 + ], + [ + 1275504.69076626, + 2828857.9792993665 + ], + [ + 1275504.69076626, + 2828781.5422710925 + ], + [ + 1275428.2537379898, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2828857.9792993665 + ], + [ + 1275428.2537379898, + 2828934.4163276367 + ], + [ + 1275504.69076626, + 2828934.4163276367 + ], + [ + 1275504.69076626, + 2828857.9792993665 + ], + [ + 1275428.2537379898, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2828934.4163276367 + ], + [ + 1275428.2537379898, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2828934.4163276367 + ], + [ + 1275428.2537379898, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2829010.853355907 + ], + [ + 1275428.2537379898, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829010.853355907 + ], + [ + 1275428.2537379898, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2829087.290384181 + ], + [ + 1275428.2537379898, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275428.2537379898, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2829163.727412451 + ], + [ + 1275428.2537379898, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275428.2537379898, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275428.2537379898, + 2829240.1644407213 + ], + [ + 1275428.2537379898, + 2829316.601468995 + ], + [ + 1275504.69076626, + 2829316.601468995 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275428.2537379898, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278830, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2828781.5422710925 + ], + [ + 1275504.69076626, + 2828857.9792993665 + ], + [ + 1275581.1277945302, + 2828857.9792993665 + ], + [ + 1275581.1277945302, + 2828781.5422710925 + ], + [ + 1275504.69076626, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2828857.9792993665 + ], + [ + 1275504.69076626, + 2828934.4163276367 + ], + [ + 1275581.1277945302, + 2828934.4163276367 + ], + [ + 1275581.1277945302, + 2828857.9792993665 + ], + [ + 1275504.69076626, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2828934.4163276367 + ], + [ + 1275504.69076626, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2828934.4163276367 + ], + [ + 1275504.69076626, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275504.69076626, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275504.69076626, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275504.69076626, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275504.69076626, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829316.601468995 + ], + [ + 1275581.1277945302, + 2829316.601468995 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275504.69076626, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278831, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2828781.5422710925 + ], + [ + 1275581.1277945302, + 2828857.9792993665 + ], + [ + 1275657.5648228042, + 2828857.9792993665 + ], + [ + 1275657.5648228042, + 2828781.5422710925 + ], + [ + 1275581.1277945302, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2828857.9792993665 + ], + [ + 1275581.1277945302, + 2828934.4163276367 + ], + [ + 1275657.5648228042, + 2828934.4163276367 + ], + [ + 1275657.5648228042, + 2828857.9792993665 + ], + [ + 1275581.1277945302, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2828934.4163276367 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2828934.4163276367 + ], + [ + 1275581.1277945302, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275581.1277945302, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275581.1277945302, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275581.1277945302, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275581.1277945302, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275581.1277945302, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278832, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2828781.5422710925 + ], + [ + 1275657.5648228042, + 2828857.9792993665 + ], + [ + 1275734.0018510744, + 2828857.9792993665 + ], + [ + 1275734.0018510744, + 2828781.5422710925 + ], + [ + 1275657.5648228042, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2828857.9792993665 + ], + [ + 1275657.5648228042, + 2828934.4163276367 + ], + [ + 1275734.0018510744, + 2828934.4163276367 + ], + [ + 1275734.0018510744, + 2828857.9792993665 + ], + [ + 1275657.5648228042, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2828934.4163276367 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2828934.4163276367 + ], + [ + 1275657.5648228042, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275657.5648228042, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275657.5648228042, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275657.5648228042, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275657.5648228042, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275657.5648228042, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278833, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2828781.5422710925 + ], + [ + 1275734.0018510744, + 2828857.9792993665 + ], + [ + 1275810.4388793446, + 2828857.9792993665 + ], + [ + 1275810.4388793446, + 2828781.5422710925 + ], + [ + 1275734.0018510744, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2828857.9792993665 + ], + [ + 1275734.0018510744, + 2828934.4163276367 + ], + [ + 1275810.4388793446, + 2828934.4163276367 + ], + [ + 1275810.4388793446, + 2828857.9792993665 + ], + [ + 1275734.0018510744, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2828934.4163276367 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2828934.4163276367 + ], + [ + 1275734.0018510744, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275734.0018510744, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275734.0018510744, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275734.0018510744, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275734.0018510744, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275734.0018510744, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278834, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2828781.5422710925 + ], + [ + 1275810.4388793446, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828781.5422710925 + ], + [ + 1275810.4388793446, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2828857.9792993665 + ], + [ + 1275810.4388793446, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275810.4388793446, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2828934.4163276367 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275810.4388793446, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275810.4388793446, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275810.4388793446, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275810.4388793446, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275810.4388793446, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829316.601468995 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275810.4388793446, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278835, + "y": 299158, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275963.3129358888, + 2828781.5422710925 + ], + [ + 1275886.8759076186, + 2828781.5422710925 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299152, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275963.3129358888, + 2828857.9792993665 + ], + [ + 1275886.8759076186, + 2828857.9792993665 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299153, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275963.3129358888, + 2828934.4163276367 + ], + [ + 1275886.8759076186, + 2828934.4163276367 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299154, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829087.290384181 + ], + [ + 1275963.3129358888, + 2829010.853355907 + ], + [ + 1275886.8759076186, + 2829010.853355907 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299155, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275963.3129358888, + 2829163.727412451 + ], + [ + 1275963.3129358888, + 2829087.290384181 + ], + [ + 1275886.8759076186, + 2829087.290384181 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299156, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275963.3129358888, + 2829240.1644407213 + ], + [ + 1275963.3129358888, + 2829163.727412451 + ], + [ + 1275886.8759076186, + 2829163.727412451 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299157, + "zoom": 19, + "isSquare": true + } + }, + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 1275886.8759076186, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829316.601468995 + ], + [ + 1275963.3129358888, + 2829316.601468995 + ], + [ + 1275963.3129358888, + 2829240.1644407213 + ], + [ + 1275886.8759076186, + 2829240.1644407213 + ] + ] + ] + ] + }, + "properties": { + "x": 278836, + "y": 299158, + "zoom": 19, + "isSquare": true + } + } + ] + } +} diff --git a/tests/api/helpers/test_helpers.py b/tests/api/helpers/test_helpers.py new file mode 100644 index 0000000000..d8280748cd --- /dev/null +++ b/tests/api/helpers/test_helpers.py @@ -0,0 +1,514 @@ +import base64 +import json +import logging +import os +import xml.etree.ElementTree as ET +from typing import Tuple + +from backend.exceptions import NotFound +import geojson + +from backend.models.dtos.organisation_dto import UpdateOrganisationDTO +from backend.models.dtos.project_dto import ( + DraftProjectDTO, + ProjectDTO, + ProjectInfoDTO, + ProjectPriority, + ProjectStatus, +) +from backend.models.postgis.campaign import Campaign +from backend.models.postgis.message import Message, MessageType +from backend.models.postgis.notification import Notification +from backend.models.postgis.organisation import Organisation +from backend.models.postgis.project import Project, ProjectTeams +from backend.models.postgis.statuses import ( + MappingLevel, + OrganisationType, + TaskStatus, + TeamJoinMethod, + TeamVisibility, +) +from backend.models.postgis.task import Task +from backend.models.postgis.team import Team, TeamMembers +from backend.models.postgis.user import User +from backend.services.interests_service import Interest +from backend.services.license_service import LicenseDTO, LicenseService +from backend.services.mapping_issues_service import ( + MappingIssueCategoryDTO, + MappingIssueCategoryService, +) +from backend.services.organisation_service import OrganisationService +from backend.services.users.authentication_service import AuthenticationService + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +TEST_USER_ID = 777777 +TEST_USERNAME = "Thinkwhere Test" +TEST_ORGANISATION_NAME = "Kathmandu Living Labs" +TEST_ORGANISATION_SLUG = "KLL" +TEST_ORGANISATION_ID = 23 +TEST_PROJECT_NAME = "Test" +TEST_TEAM_NAME = "Test Team" +TEST_CAMPAIGN_NAME = "Test Campaign" +TEST_CAMPAIGN_ID = 1 +TEST_MESSAGE_SUBJECT = "Test subject" +TEST_MESSAGE_DETAILS = "This is a test message" + + +def get_canned_osm_user_details(): + """Helper method to find test file, dependent on where tests are being run from""" + + location = os.path.join( + os.path.dirname(__file__), "test_files", "osm_user_details.json" + ) + try: + with open(location, "r") as x: + return json.load(x) + except FileNotFoundError: + raise FileNotFoundError("osm_user_details.json not found") + + +def get_canned_osm_user_json_details(): + """Helper method to find test file, dependent on where tests are being run from""" + + location = os.path.join( + os.path.dirname(__file__), "test_files", "osm_user_details.json" + ) + try: + with open(location, "r") as x: + return json.load(x) + except FileNotFoundError: + raise FileNotFoundError("osm_user_details.json not found") + + +def get_canned_osm_user_details_changed_name(): + """Helper method to find test file, dependent on where tests are being run from""" + + location = os.path.join( + os.path.dirname(__file__), "test_files", "osm_user_details_changed_name.xml" + ) + + try: + with open(location, "r"): + return ET.parse(location) + except FileNotFoundError: + raise FileNotFoundError("osm_user_details_changed_name.xml not found") + + +def get_canned_json(name_of_file): + """Read canned Grid request from file""" + + location = os.path.join(os.path.dirname(__file__), "test_files", name_of_file) + + try: + with open(location, "r") as grid_file: + data = json.load(grid_file) + + return data + except FileNotFoundError: + raise FileNotFoundError("json file not found") + + +def get_canned_simplified_osm_user_details(): + """Helper that reads file and returns it as a string""" + location = os.path.join( + os.path.dirname(__file__), "test_files", "osm_user_details_simple.xml" + ) + + with open(location, "r") as osm_file: + data = osm_file.read().replace("\n", "") + + return data + + +def return_canned_user(username=TEST_USERNAME, id=TEST_USER_ID) -> User: + """Returns a canned user""" + test_user = User() + test_user.username = username + test_user.id = id + test_user.mapping_level = MappingLevel.BEGINNER.value + test_user.email_address = None + test_user.role = 0 + test_user.tasks_mapped = 0 + test_user.tasks_validated = 0 + test_user.tasks_invalidated = 0 + test_user.is_email_verified = False + test_user.is_expert = False + test_user.default_editor = "ID" + test_user.mentions_notifications = True + test_user.projects_comments_notifications = False + test_user.projects_notifications = True + test_user.tasks_notifications = True + test_user.tasks_comments_notifications = False + test_user.teams_announcement_notifications = True + return test_user + + +def generate_encoded_token(user_id: int): + "Returns encoded session token along with token scheme" + + session_token = AuthenticationService.generate_session_token_for_user(user_id) + session_token = base64.b64encode(session_token.encode("utf-8")) + return "Token " + session_token.decode("utf-8") + + +async def create_canned_user(db, test_user=None): + """Generate a canned user in the DB""" + if test_user is None: + test_user = return_canned_user() + + # Make sure all required values are passed to the INSERT query (including non-nullable defaults) + await db.execute( + """ + INSERT INTO users ( + id, username, role, mapping_level, tasks_mapped, tasks_validated, tasks_invalidated, + email_address, is_email_verified, is_expert, default_editor, mentions_notifications, + projects_comments_notifications, projects_notifications, tasks_notifications, + tasks_comments_notifications, teams_announcement_notifications, date_registered, + last_validation_date + ) + VALUES ( + :id, :username, :role, :mapping_level, :tasks_mapped, :tasks_validated, :tasks_invalidated, + :email_address, :is_email_verified, :is_expert, :default_editor, :mentions_notifications, + :projects_comments_notifications, :projects_notifications, :tasks_notifications, + :tasks_comments_notifications, :teams_announcement_notifications, :date_registered, + :last_validation_date + ) + """, + { + "id": test_user.id, + "username": test_user.username, + "role": test_user.role, + "mapping_level": test_user.mapping_level, + "tasks_mapped": test_user.tasks_mapped, + "tasks_validated": test_user.tasks_validated, + "tasks_invalidated": test_user.tasks_invalidated, + "email_address": test_user.email_address, + "is_email_verified": test_user.is_email_verified, + "is_expert": test_user.is_expert, + "default_editor": test_user.default_editor, + "mentions_notifications": test_user.mentions_notifications, + "projects_comments_notifications": test_user.projects_comments_notifications, + "projects_notifications": test_user.projects_notifications, + "tasks_notifications": test_user.tasks_notifications, + "tasks_comments_notifications": test_user.tasks_comments_notifications, + "teams_announcement_notifications": test_user.teams_announcement_notifications, + "date_registered": test_user.date_registered, + "last_validation_date": test_user.last_validation_date, + }, + ) + return test_user + + +async def get_canned_user(username: str, db) -> User: + test_user = await User().get_by_username(username, db) + return test_user + + +async def create_canned_project(db, name=TEST_PROJECT_NAME) -> Tuple[Project, User]: + """Generates a canned project in the DB to help with integration tests""" + test_aoi_geojson = geojson.loads(json.dumps(get_canned_json("test_aoi.json"))) + + task_feature = geojson.loads(json.dumps(get_canned_json("splittable_task.json"))) + task_non_square_feature = geojson.loads( + json.dumps(get_canned_json("non_square_task.json")) + ) + task_arbitrary_feature = geojson.loads( + json.dumps(get_canned_json("splittable_task.json")) + ) + test_user = await get_canned_user(TEST_USERNAME, db) + if test_user is None: + test_user = await create_canned_user(db) + + try: + org_record = await OrganisationService.get_organisation_by_id(23, db) + except NotFound: + test_org = await create_canned_organisation(db) + org_record = await OrganisationService.get_organisation_by_id(test_org.id, db) + + test_project_dto = DraftProjectDTO(project_name=name) + test_project_dto.user_id = test_user.id + test_project_dto.area_of_interest = test_aoi_geojson + test_project_dto.organisation = org_record + test_project = Project() + test_project.create_draft_project(test_project_dto) + await test_project.set_project_aoi(test_project_dto, db) + test_project.total_tasks = 3 + + # Setup test task + test_task = Task.from_geojson_feature(1, task_feature) + test_task.task_status = TaskStatus.MAPPED.value + test_task.mapped_by = test_user.id + test_task.is_square = True + + test_task2 = Task.from_geojson_feature(2, task_non_square_feature) + test_task2.task_status = TaskStatus.READY.value + test_task2.is_square = False + + test_task3 = Task.from_geojson_feature(3, task_arbitrary_feature) + test_task3.task_status = TaskStatus.BADIMAGERY.value + test_task3.mapped_by = test_user.id + test_task3.is_square = True + + test_task4 = Task.from_geojson_feature(4, task_feature) + test_task4.task_status = TaskStatus.VALIDATED.value + test_task4.mapped_by = test_user.id + test_task4.validated_by = test_user.id + test_task4.is_square = True + + test_project.tasks.append(test_task) + test_project.tasks.append(test_task2) + test_project.tasks.append(test_task3) + test_project.tasks.append(test_task4) + test_project.total_tasks = 4 + test_project.tasks_mapped = 1 + test_project.tasks_validated = 1 + test_project.tasks_bad_imagery = 1 + project_id = await test_project.create(name, db) + query = """ + UPDATE tasks + SET task_status = CASE + WHEN id = 1 THEN 2 + WHEN id = 2 THEN 0 + WHEN id = 3 THEN 6 + WHEN id = 4 THEN 4 + END + WHERE id IN (1, 2, 3, 4); + """ + await db.execute(query) + test_project.set_default_changeset_comment() + return test_project, test_user, project_id + + +def return_canned_draft_project_json(): + """Helper method to find test file, dependent on where tests are being run from""" + + location = os.path.join( + os.path.dirname(__file__), "test_files", "canned_draft_project.json" + ) + try: + with open(location, "r") as x: + a = json.load(x) + return a + except FileNotFoundError: + raise FileNotFoundError("canned_draft_project.json not found") + + +def return_canned_organisation( + org_id=TEST_ORGANISATION_ID, + org_name=TEST_ORGANISATION_NAME, + org_slug=TEST_ORGANISATION_SLUG, +) -> Organisation: + "Returns test organisation without writing to db" + test_org = Organisation() + test_org.id = org_id + test_org.name = org_name + test_org.slug = org_slug + test_org.type = OrganisationType.FREE.value + + return test_org + + +async def create_canned_organisation(db): + """Generate a canned organisation in the DB""" + test_org = return_canned_organisation() + await db.execute( + """ + INSERT INTO organisations (id, name, slug, type) + VALUES (:id, :name, :slug, :type) + """, + { + "id": test_org.id, + "name": test_org.name, + "slug": test_org.slug, + "type": test_org.type, + }, + ) + return test_org + + +async def get_canned_organisation(org_name: str, db) -> Organisation: + organisation = await Organisation.get_organisation_by_name(org_name, db) + return organisation + + +async def return_canned_team( + db, name=TEST_TEAM_NAME, org_name=TEST_ORGANISATION_NAME +) -> Team: + """Returns test team without writing to db""" + test_team = Team() + test_team.name = name + test_org = await get_canned_organisation(org_name, db) + if test_org is None: + test_org = await create_canned_organisation(db) + test_team.organisation = test_org + test_team.organisation_id = test_org.id + + return test_team + + +async def create_canned_team(db): + test_team = await return_canned_team(db) + + query = """ + INSERT INTO teams (name, organisation_id, join_method, visibility) + VALUES (:name, :organisation_id, :join_method, :visibility) + RETURNING id + """ + + created_team_id = await db.fetch_one( + query, + { + "name": test_team.name, + "organisation_id": test_team.organisation_id, + "join_method": TeamJoinMethod.ANY.value, + "visibility": TeamVisibility.PUBLIC.value, + }, + ) + + created_team = await db.fetch_one( + """ + SELECT id, name, description, join_method, visibility, organisation_id + FROM teams + WHERE id = :id + """, + {"id": created_team_id["id"]}, + ) + + return Team(**created_team) if created_team else None + + +def add_user_to_team( + team: Team, user: User, role: int, is_active: bool, db +) -> TeamMembers: + team_member = TeamMembers(team=team, member=user, function=role, active=is_active) + team_member.create(db) + + return team_member + + +def add_manager_to_organisation(organisation: Organisation, user: User): + org_dto = UpdateOrganisationDTO() + org_dto.managers = [user.username] + organisation.update(org_dto) + organisation.save() + return user.username + + +def assign_team_to_project(project: Project, team: Team, role: int, db) -> ProjectTeams: + project_team = ProjectTeams(project=project, team=team, role=role) + project_team.create(db) + + return project_team + + +def update_project_with_info(test_project: Project) -> Project: + locales = [] + test_info = ProjectInfoDTO() + test_info.locale = "en" + test_info.name = "Thinkwhere Test" + test_info.description = "Test Description" + test_info.short_description = "Short description" + test_info.instructions = "Instructions" + locales.append(test_info) + + test_dto = ProjectDTO() + test_dto.project_status = ProjectStatus.PUBLISHED.name + test_dto.project_priority = ProjectPriority.MEDIUM.name + test_dto.default_locale = "en" + test_dto.project_info_locales = locales + test_dto.difficulty = "EASY" + test_dto.mapping_types = ["ROADS"] + test_dto.mapping_editors = ["JOSM", "ID"] + test_dto.validation_editors = ["JOSM"] + test_dto.changeset_comment = "hot-project" + test_dto.private = False + test_project.update(test_dto) + + return test_project + + +def return_canned_campaign( + id=TEST_CAMPAIGN_ID, + name=TEST_CAMPAIGN_NAME, + description=None, + logo=None, +) -> Campaign: + """Returns test campaign without writing to db""" + test_campaign = Campaign() + test_campaign.id = id + test_campaign.name = name + test_campaign.description = description + test_campaign.logo = logo + + return test_campaign + + +def create_canned_campaign( + id=TEST_CAMPAIGN_ID, + name=TEST_CAMPAIGN_NAME, + description=None, + logo=None, +) -> Campaign: + """Creates test campaign without writing to db""" + test_campaign = return_canned_campaign(id, name, description, logo) + test_campaign.create() + + return test_campaign + + +def create_canned_interest(name="test_interest") -> Interest: + """Returns test interest without writing to db + param name: name of interest + return: Interest object + """ + test_interest = Interest() + test_interest.name = name + test_interest.create() + return test_interest + + +def create_canned_license(name="test_license") -> int: + """Returns test license without writing to db + param name: name of license + return: license id + """ + license_dto = LicenseDTO() + license_dto.name = name + license_dto.description = "test license" + license_dto.plain_text = "test license" + test_license = LicenseService.create_licence(license_dto) + return test_license + + +def create_canned_mapping_issue(name="Test Issue") -> int: + issue_dto = MappingIssueCategoryDTO() + issue_dto.name = name + test_issue_id = MappingIssueCategoryService.create_mapping_issue_category(issue_dto) + return test_issue_id + + +def create_canned_message( + subject=TEST_MESSAGE_SUBJECT, + message=TEST_MESSAGE_DETAILS, + message_type=MessageType.SYSTEM.value, + db=None, +) -> Message: + test_message = Message() + test_message.subject = subject + test_message.message = message + test_message.message_type = message_type + test_message.save(db) + return test_message + + +def create_canned_notification(user_id, unread_count, date) -> Notification: + test_notification = Notification() + test_notification.user_id = user_id + test_notification.unread_count = unread_count + test_notification.date = date + test_notification.save() + return test_notification diff --git a/tests/api/integration/api/system/test_banner.py b/tests/api/integration/api/system/test_banner.py new file mode 100644 index 0000000000..dbdf458271 --- /dev/null +++ b/tests/api/integration/api/system/test_banner.py @@ -0,0 +1,110 @@ +import base64 +import pytest +import logging + +from httpx import AsyncClient +from backend.services.users.authentication_service import AuthenticationService +from tests.api.helpers.test_helpers import return_canned_user +from tests.api.helpers.test_helpers import create_canned_user + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@pytest.mark.anyio +class TestBannerAPI: + + @pytest.fixture(autouse=True) + def _setup(self): + self.url = "/api/v2/system/banner/" + + async def test_get_banner(self, client: AsyncClient): + logger.info("Starting test: GET banner") + response = await client.get(self.url) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Welcome to the API" + assert data["visible"] is True + logger.info("Completed test: GET banner") + + async def test_patch_banner_invalid_data( + self, client: AsyncClient, db_connection_fixture + ): + logger.info("Starting test: PATCH banner with invalid data") + + test_user = return_canned_user() + await create_canned_user(db_connection_fixture, test_user) + + session_token = AuthenticationService.generate_session_token_for_user( + test_user.id + ) + session_token = base64.b64encode(session_token.encode("utf-8")).decode("utf-8") + auth_header = {"Authorization": f"Token {session_token}"} + logger.info("" f"Token {session_token}") + + response = await client.patch( + self.url, + json={"message": "### Updated message", "visible": "OK"}, + headers=auth_header, + ) + assert response.status_code == 422 + + body = response.json() + assert "detail" in body + first_error = body["detail"][0] + assert first_error["loc"] == ["body", "visible"] + assert first_error["type"] == "bool_parsing" + + async def test_patch_banner_non_admin( + self, client: AsyncClient, db_connection_fixture + ): + logger.info("Starting test: PATCH banner with non-admin user") + + test_user = return_canned_user() + await create_canned_user(db_connection_fixture, test_user) + + session_token = AuthenticationService.generate_session_token_for_user( + test_user.id + ) + session_token = base64.b64encode(session_token.encode("utf-8")).decode("utf-8") + auth_header = {"Authorization": f"Token {session_token}"} + + response = await client.patch( + self.url, + json={"message": "### Updated message", "visible": True}, + headers=auth_header, + ) + + assert response.status_code == 403 + assert response.json()["SubCode"] == "OnlyAdminAccess" + logger.info("Completed test: PATCH banner with non-admin user") + + async def test_patch_banner_admin(self, client: AsyncClient, db_connection_fixture): + logger.info("Starting test: PATCH banner with admin user") + + test_user = return_canned_user() + test_user.role = 1 # Admin + await create_canned_user(db_connection_fixture, test_user) + await db_connection_fixture.execute( + """ + INSERT INTO banner (id, message, visible) + VALUES (:id, :message, :visible) + """, + {"id": 10, "message": "Welcome to the API", "visible": True}, + ) + session_token = AuthenticationService.generate_session_token_for_user( + test_user.id + ) + session_token = base64.b64encode(session_token.encode("utf-8")).decode("utf-8") + auth_header = {"Authorization": f"Token {session_token}"} + response = await client.patch( + self.url, + json={"message": "### Updated message", "visible": True}, + headers=auth_header, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "

Updated message

" + assert data["visible"] is True + logger.info("Completed test: PATCH banner with admin user") diff --git a/tests/api/integration/api/system/test_general.py b/tests/api/integration/api/system/test_general.py new file mode 100644 index 0000000000..3996fc6c21 --- /dev/null +++ b/tests/api/integration/api/system/test_general.py @@ -0,0 +1,83 @@ +import pytest +import logging +from unittest.mock import patch +from httpx import AsyncClient + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@pytest.mark.anyio +class TestSystemHeartbeatAPI: + + @pytest.fixture(autouse=True) + def _setup(self): + self.url = "/api/v2/system/heartbeat/" + + async def test_get_system_heartbeat(self, client: AsyncClient): + logger.info("Starting test: system heartbeat") + response = await client.get(self.url) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "Fastapi healthy" + # ensure release key exists (but we won't assert its value here) + assert "release" in body + logger.info("Completed test: system heartbeat") + + +@pytest.mark.anyio +class TestSystemLanguagesAPI: + + @pytest.fixture(autouse=True) + def _setup(self): + self.url = "/api/v2/system/languages/" + + async def test_get_system_languages(self, client: AsyncClient): + logger.info("Starting test: system languages") + response = await client.get(self.url) + assert response.status_code == 200 + + keys = list(response.json().keys()) + assert set(keys) == { + "mapperLevelIntermediate", + "mapperLevelAdvanced", + "supportedLanguages", + } + logger.info("Completed test: system languages") + + +@pytest.mark.anyio +class TestSystemContactAdminRestAPI: + + @pytest.fixture(autouse=True) + def _setup(self): + self.url = "/api/v2/system/contact-admin/" + + @patch("backend.config.settings.EMAIL_CONTACT_ADDRESS", "test@hotosm.org") + async def test_post_contact_admin(self, client: AsyncClient): + logger.info("Starting test: contact-admin success") + + payload = {"name": "test", "email": "test", "content": "test"} + response = await client.post(self.url, json=payload) + assert response.status_code == 201 + + body = response.json() + assert body["Success"] == "Email sent" + logger.info("Completed test: contact-admin success") + + @patch("backend.config.settings.EMAIL_CONTACT_ADDRESS", None) + async def test_post_contact_admin_raises_error_if_email_contact_address_not_set( + self, client: AsyncClient + ): + logger.info("Starting test: contact-admin missing address") + + payload = {"name": "test", "email": "test@gmail.com", "content": "test"} + response = await client.post(self.url, json=payload) + assert response.status_code == 501 + + body = response.json() + assert body["Error"] == ( + "This feature is not implemented due to missing variable " + "TM_EMAIL_CONTACT_ADDRESS." + ) + logger.info("Completed test: contact-admin missing address") diff --git a/tests/api/integration/api/system/test_statistics.py b/tests/api/integration/api/system/test_statistics.py new file mode 100644 index 0000000000..4761406c56 --- /dev/null +++ b/tests/api/integration/api/system/test_statistics.py @@ -0,0 +1,47 @@ +import pytest +import logging + +from httpx import AsyncClient +from backend.models.postgis.task import Task +from tests.api.helpers.test_helpers import ( + return_canned_user, + create_canned_project, + create_canned_user, +) + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@pytest.mark.anyio +class TestSystemStatisticsAPI: + + @pytest.fixture(autouse=True) + def _setup(self): + self.url = "/api/v2/system/statistics/" + + async def test_returns_home_page_stats( + self, client: AsyncClient, db_connection_fixture + ): + logger.info("Starting test: home page statistics") + + test_user = return_canned_user("Test User", 2222222) + await create_canned_user(db_connection_fixture, test_user) + + project, _, project_id = await create_canned_project(db_connection_fixture) + + # Lock a task for mapping as mappers online is calculated based on locked tasks + # Set task 2 to mapped since it's created unmapped + await Task.lock_task_for_mapping( + 2, project_id, test_user.id, db_connection_fixture + ) + + response = await client.get(self.url) + assert response.status_code == 200 + + data = response.json() + + assert data["mappersOnline"] == 1 + assert data["tasksMapped"] == 2 + assert data["totalMappers"] == 2 + assert data["totalProjects"] == 1 diff --git a/tests/api/test_hearbeat.py b/tests/api/test_hearbeat.py new file mode 100644 index 0000000000..a56f13f984 --- /dev/null +++ b/tests/api/test_hearbeat.py @@ -0,0 +1,18 @@ +import pytest +import logging + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@pytest.mark.anyio +class TestSystemHealth: + async def test_heartbeat(self, client): + logger.info("Starting test: heartbeat without release version data.") + response = await client.get("/api/v2/system/heartbeat/") + logger.info("Response status code: %s", response.status_code) + assert response.status_code == 200 + data = response.json() + logger.info("Response JSON: %s", data) + assert data["status"] == "Fastapi healthy" + logger.info("Completed test: heartbeat without release version data.") diff --git a/tests/api/unit/models/postgis/test_banner.py b/tests/api/unit/models/postgis/test_banner.py new file mode 100644 index 0000000000..7241481c51 --- /dev/null +++ b/tests/api/unit/models/postgis/test_banner.py @@ -0,0 +1,68 @@ +import pytest + +from backend.models.dtos.banner_dto import BannerDTO +from backend.models.postgis.banner import Banner + +TEST_USERNAME = "test_user" +TEST_USER_ID = 1 + + +@pytest.mark.anyio +class TestBanner: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test user before each test.""" + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + request.cls.message = "Test message" + request.cls.visible = True + + async def create_banner(self): + banner = Banner() + banner.message = self.message + banner.visible = self.visible + await banner.create(self.db) + + async def test_create_banner(self): + """Test creating a banner.""" + await self.create_banner() + + banner = await Banner.get(self.db) + + assert banner is not None + assert banner["message"] == "Test message" + assert banner["visible"] is True + + async def test_get_banner_creates_one_if_none(self): + """Test retrieving a banner.""" + + fetched_banner = await Banner.get(self.db) + + assert fetched_banner is not None + assert fetched_banner.message == "Welcome to the API" + assert fetched_banner.visible is True + + async def test_update_banner_from_dto(self): + """Test updating a banner using DTO.""" + banner = Banner(message="Old message", visible=False) + await banner.create(self.db) + + banner_record = await Banner.get(self.db) + banner = Banner(**banner_record) + + banner_dto = BannerDTO(message="Updated message", visible=True) + await banner.update_from_dto(self.db, banner_dto) + + updated_banner = await Banner.get(self.db) + + assert updated_banner.message == "Updated message" + assert updated_banner.visible is True + + async def test_to_html(self): + """Test converting banner message to HTML.""" + banner = Banner(message="Test message", visible=True) + await banner.create(self.db) + banner_record = await Banner.get(self.db) + banner = Banner(**banner_record) + html = banner.to_html(f"### {banner.message}") + assert html == "

Test message

" diff --git a/tests/api/unit/models/postgis/test_custom_editor.py b/tests/api/unit/models/postgis/test_custom_editor.py new file mode 100644 index 0000000000..921c6811fe --- /dev/null +++ b/tests/api/unit/models/postgis/test_custom_editor.py @@ -0,0 +1,63 @@ +import pytest +from backend.models.postgis.custom_editors import CustomEditor, CustomEditorDTO +from tests.api.helpers.test_helpers import create_canned_project + + +@pytest.mark.anyio +class TestCustomEditor: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test database fixture.""" + assert db_connection_fixture is not None, "Database connection is not available" + test_project, author_id, project_id = await create_canned_project( + db_connection_fixture + ) + + custom_editor_dto = CustomEditorDTO( + name="Test custom editor", + description="Test description", + url="Test URL", + ) + + request.cls.db = db_connection_fixture + request.cls.test_project = test_project + request.cls.project_id = project_id + request.cls.author_id = author_id + request.cls.custom_editor_dto = custom_editor_dto + + await CustomEditor.create_from_dto(project_id, custom_editor_dto, self.db) + + async def test_get_by_project_id(self): + """Test fetching a custom editor by project ID.""" + custom_editor = await CustomEditor.get_by_project_id(self.project_id, self.db) + + assert custom_editor is not None + assert custom_editor.project_id == self.project_id + assert custom_editor.name == self.custom_editor_dto.name + assert custom_editor.description == self.custom_editor_dto.description + assert custom_editor.url == self.custom_editor_dto.url + + async def test_update_editor(self): + """Test updating a custom editor from DTO.""" + custom_editor_dto = CustomEditorDTO( + name="Updated Name", + description="Updated Description", + url="Updated URL", + ) + + await CustomEditor.update_editor(self.project_id, custom_editor_dto, self.db) + + updated_editor = await CustomEditor.get_by_project_id(self.project_id, self.db) + + assert updated_editor is not None + assert updated_editor.project_id == self.project_id + assert updated_editor.name == custom_editor_dto.name + assert updated_editor.description == custom_editor_dto.description + assert updated_editor.url == custom_editor_dto.url + + async def test_delete(self): + """Test deleting a custom editor.""" + await CustomEditor.delete(self.project_id, self.db) + custom_editor = await CustomEditor.get_by_project_id(self.project_id, self.db) + + assert custom_editor is None diff --git a/tests/api/unit/models/postgis/test_message.py b/tests/api/unit/models/postgis/test_message.py new file mode 100644 index 0000000000..b3ab78f8e7 --- /dev/null +++ b/tests/api/unit/models/postgis/test_message.py @@ -0,0 +1,146 @@ +import pytest + +from backend.models.postgis.message import Message, MessageType +from backend.services.messaging.message_service import MessageService +from tests.api.helpers.test_helpers import create_canned_user, return_canned_user + +TEST_USERNAME = "test_user" +TEST_USER_ID = 1 + + +@pytest.mark.anyio +class TestMessage: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test user before each test.""" + assert db_connection_fixture is not None, "Database connection is not available" + test_user = return_canned_user(TEST_USERNAME, TEST_USER_ID) + await create_canned_user(db_connection_fixture, test_user) + + request.cls.db = db_connection_fixture + request.cls.test_user_id = TEST_USER_ID + request.cls.test_user = test_user + + async def send_multiple_welcome_messages(self, number_of_messages: int): + """Sends multiple welcome messages using raw SQL.""" + for _ in range(number_of_messages): + await MessageService.send_welcome_message(self.test_user, self.db) + + query = "SELECT id FROM messages WHERE to_user_id = :user_id" + messages = await self.db.fetch_all(query, {"user_id": self.test_user_id}) + message_ids = [message.id for message in messages] + return message_ids + + async def test_delete_multiple_messages(self): + """Tests that multiple messages can be deleted.""" + message_ids = await self.send_multiple_welcome_messages(3) + await Message.delete_multiple_messages( + message_ids[:2], self.test_user.id, self.db + ) + + # Verify remaining messages + + query = "SELECT id FROM messages WHERE to_user_id = :user_id" + messages = await self.db.fetch_all(query, {"user_id": self.test_user_id}) + + assert len(messages) == 1 + assert messages[0]["id"] == message_ids[2] + + async def test_delete_all_messages(self): + """Tests that all messages can be deleted.""" + await self.send_multiple_welcome_messages(3) + await Message.delete_all_messages(self.test_user.id, self.db) + # Verify deletion + query = "SELECT COUNT(*) FROM messages WHERE to_user_id = :user_id" + result = await self.db.fetch_one(query, {"user_id": self.test_user_id}) + assert result[0] == 0 + + async def test_delete_all_message_filters_by_type(self): + """Tests that all messages can be deleted by type.""" + await self.send_multiple_welcome_messages(3) + + test_user_2 = return_canned_user("test_user_2", 222222222) + await create_canned_user(self.db, test_user_2) + await MessageService.send_team_join_notification( + test_user_2.id, + test_user_2.username, + self.test_user.id, + "test_team", + 10, + "MANAGER", + self.db, + ) + # Act + await Message.delete_all_messages( + self.test_user.id, self.db, [MessageType.SYSTEM.value] + ) + # Assert + query = "SELECT * FROM messages WHERE to_user_id = :user_id" + messages = await self.db.fetch_all(query, {"user_id": self.test_user_id}) + + assert len(messages) == 1 + assert messages[0].message_type == MessageType.INVITATION_NOTIFICATION.value + + async def test_mark_multiple_messages_read(self): + """Tests that multiple messages can be marked as read.""" + message_ids = await self.send_multiple_welcome_messages(3) + + await Message.mark_multiple_messages_read( + message_ids[:2], self.test_user.id, self.db + ) + # Verify updates + query = "SELECT id, read FROM messages WHERE to_user_id = :user_id ORDER BY id" + messages = await self.db.fetch_all(query, {"user_id": self.test_user_id}) + + assert messages[0]["read"] is True + assert messages[1]["read"] is True + assert messages[2]["read"] is False + + async def test_mark_all_messages_read(self): + """Tests that all messages can be marked as read.""" + await self.send_multiple_welcome_messages(3) + + await Message.mark_all_messages_read(self.test_user.id, self.db) + + # Verify updates + query = ( + "SELECT COUNT(*) FROM messages WHERE to_user_id = :user_id AND read = FALSE" + ) + result = await self.db.fetch_one(query, {"user_id": self.test_user_id}) + assert result[0] == 0 + + async def test_mark_all_message_filters_by_type(self): + """Test that all messages of a certain type can be marked as read.""" + await self.send_multiple_welcome_messages(3) + + test_user_2 = return_canned_user("test_user_2", 222222222) + await create_canned_user(self.db, test_user_2) + + await MessageService.send_team_join_notification( + test_user_2.id, + test_user_2.username, + self.test_user.id, + "test_team", + 10, + "MANAGER", + self.db, + ) + # Act + await Message.mark_all_messages_read( + self.test_user.id, self.db, [MessageType.SYSTEM.value] + ) + # Assert + messages = await MessageService.get_all_messages( + db=self.db, + user_id=self.test_user.id, + locale="en", + page=1, + sort_by="date", + sort_direction="desc", + status="unread", + ) + assert len(messages.user_messages) == 1 + assert ( + messages.user_messages[0].message_type + == MessageType.INVITATION_NOTIFICATION.name + ) diff --git a/tests/api/unit/models/postgis/test_organisation.py b/tests/api/unit/models/postgis/test_organisation.py new file mode 100644 index 0000000000..70b645db72 --- /dev/null +++ b/tests/api/unit/models/postgis/test_organisation.py @@ -0,0 +1,98 @@ +import pytest +from backend.models.dtos.organisation_dto import OrganisationDTO +from backend.models.postgis.organisation import OrganisationType +from tests.api.helpers.test_helpers import ( + create_canned_organisation, + create_canned_user, +) + + +@pytest.mark.anyio +class TestOrganisation: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.test_org = await create_canned_organisation(db_connection_fixture) + request.cls.test_user = await create_canned_user(db_connection_fixture) + + assert self.test_org is not None, "Failed to create test organisation" + assert self.test_user is not None, "Failed to create test user" + + async def test_get_organisations_managed_by_user(self, db_connection_fixture): + """Test fetching organisations managed by a user.""" + # Assign the user as a manager + await db_connection_fixture.execute( + """ + INSERT INTO organisation_managers (organisation_id, user_id) + VALUES (:organisation_id, :user_id) + """, + {"organisation_id": self.test_org.id, "user_id": self.test_user.id}, + ) + + # Fetch organisations managed by the user + organisations = await db_connection_fixture.fetch_all( + """ + SELECT o.id, o.name FROM organisations o + JOIN organisation_managers om ON o.id = om.organisation_id + WHERE om.user_id = :user_id + """, + {"user_id": self.test_user.id}, + ) + + assert len(organisations) == 1 + assert organisations[0].name == self.test_org.name + + async def test_as_dto(self, db_connection_fixture): + """Test organisation DTO conversion with and without managers.""" + # Assign the user as a manager + await db_connection_fixture.execute( + """ + INSERT INTO organisation_managers (organisation_id, user_id) + VALUES (:organisation_id, :user_id) + """, + {"organisation_id": self.test_org.id, "user_id": self.test_user.id}, + ) + + # Fetch organisation details + org_record = await db_connection_fixture.fetch_one( + """ + SELECT id, name, slug, logo, description, url, type, subscription_tier + FROM organisations WHERE id = :id + """, + {"id": self.test_org.id}, + ) + + # Fetch managers + managers = await db_connection_fixture.fetch_all( + """ + SELECT u.id, u.username FROM users u + JOIN organisation_managers om ON u.id = om.user_id + WHERE om.organisation_id = :id + """, + {"id": self.test_org.id}, + ) + + # Convert to DTO + org_dto = OrganisationDTO( + organisation_id=org_record["id"], + name=org_record["name"], + slug=org_record["slug"], + logo=org_record["logo"], + description=org_record["description"], + url=org_record["url"], + type=OrganisationType(org_record["type"]).name, + subscription_tier=org_record["subscription_tier"], + managers=[{"id": m["id"], "username": m["username"]} for m in managers], + ) + + # Assertions + assert org_dto.organisation_id == self.test_org.id + assert org_dto.name == self.test_org.name + assert org_dto.slug == self.test_org.slug + assert len(org_dto.managers) == 1 + assert org_dto.managers[0].username == self.test_user.username + + # Test omitting managers + org_dto.managers = [] + assert len(org_dto.managers) == 0 diff --git a/tests/api/unit/models/postgis/test_project.py b/tests/api/unit/models/postgis/test_project.py new file mode 100644 index 0000000000..f6b5318241 --- /dev/null +++ b/tests/api/unit/models/postgis/test_project.py @@ -0,0 +1,89 @@ +from unittest.mock import AsyncMock + +import pytest +from backend.config import settings +from backend.exceptions import NotFound +from backend.models.dtos.project_dto import DraftProjectDTO +from backend.models.postgis.project import Project +from backend.models.postgis.project_info import ProjectInfo +from backend.services.organisation_service import OrganisationService +from tests.api.helpers.test_helpers import ( + create_canned_organisation, + create_canned_project, + create_canned_user, + return_canned_draft_project_json, +) + + +@pytest.mark.anyio +class TestProject: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test database fixture.""" + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + async def test_clone_project_raises_error_if_project_not_found(self): + """Test cloning a non-existent project raises NotFound error.""" + # Arrange + Project.get = AsyncMock(return_value=None) + + # Act / Assert + with pytest.raises(NotFound): + await Project.clone(12, 777777, self.db) + + async def test_clone_project_creates_copy_of_orig_project(self): + """Test cloning a project creates an identical copy.""" + # Arrange + orig_project, author, project_id = await create_canned_project(self.db) + + orig_project_info = await ProjectInfo.get_dto_for_locale( + self.db, project_id, orig_project.default_locale + ) + # Act + new_proj = await Project.clone(project_id, author.id, self.db) + new_proj_info = await ProjectInfo.get_dto_for_locale( + self.db, new_proj.id, new_proj.default_locale + ) + + # Assert + assert new_proj is not None + assert new_proj.author_id == orig_project.author_id + assert new_proj_info.name == orig_project_info.name + + async def test_create_draft_project(self): + """Test creating a draft project from a DTO.""" + # Arrange + draft_project_dto = DraftProjectDTO(**return_canned_draft_project_json()) + test_user = await create_canned_user(self.db) + test_org = await create_canned_organisation(self.db) + org_record = await OrganisationService.get_organisation_by_id( + test_org.id, self.db + ) + draft_project_dto.user_id = test_user.id + draft_project_dto.organisation = org_record + draft_project = Project() + # Act + draft_project.create_draft_project(draft_project_dto) + + async def test_set_default_changeset_comment(self): + """Test setting the default changeset comment.""" + # Arrange + test_project = Project() + expected_comment = settings.DEFAULT_CHANGESET_COMMENT + expected_comment = f"{expected_comment}-{test_project.id}" + # Act + test_project.set_default_changeset_comment() + # Assert + assert test_project.changeset_comment == expected_comment + + async def test_set_country_info(self): + """Test setting the country info for a project.""" + # Arrange + test_project, _author_id, project_id = await create_canned_project(self.db) + # Act + test_project.set_country_info() + # Assert + assert test_project.country is not None + assert len(test_project.country) > 0, "Nominatim may have given a bad response" + assert test_project.country == ["United Kingdom"] diff --git a/tests/api/unit/models/postgis/test_project_info.py b/tests/api/unit/models/postgis/test_project_info.py new file mode 100644 index 0000000000..9db2c3df18 --- /dev/null +++ b/tests/api/unit/models/postgis/test_project_info.py @@ -0,0 +1,23 @@ +import pytest +from backend.models.postgis.project_info import ProjectInfo + + +@pytest.mark.anyio +class TestProjectInfo: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test database fixture.""" + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + async def test_create_from_name(self): + """Test creating a project info instance from a name.""" + # Arrange + name = "Test Project" + + # Act + project_info = ProjectInfo.create_from_name(name) + + # Assert + assert project_info is not None + assert project_info.name == name diff --git a/tests/api/unit/models/postgis/test_task.py b/tests/api/unit/models/postgis/test_task.py new file mode 100644 index 0000000000..6d626dbeae --- /dev/null +++ b/tests/api/unit/models/postgis/test_task.py @@ -0,0 +1,264 @@ +import pytest +import geojson +from backend.models.postgis.statuses import TaskStatus +from backend.models.postgis.task import ( + InvalidData, + InvalidGeoJson, + Task, + TaskAction, +) +from tests.api.helpers.test_helpers import create_canned_project +import json + + +@pytest.mark.anyio +class TestTask: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Fixture to set up test task data.""" + assert db_connection_fixture is not None, "Database connection is not available" + + ( + request.cls.project, + request.cls.user, + request.cls.project_id, + ) = await create_canned_project(db_connection_fixture) + request.cls.mapped_task = self.project.tasks[0] + request.cls.ready_task = self.project.tasks[1] + request.cls.bad_imagery_task = self.project.tasks[2] + request.cls.validated_task = self.project.tasks[3] + request.cls.db = db_connection_fixture + + async def test_reset_task_sets_to_ready_status(self): + """Test resetting a task sets it to READY status.""" + user_id = self.user.id + + await Task.reset_task(1, self.project_id, user_id, self.db) + await Task.set_task_history( + 1, + self.project_id, + user_id, + TaskAction.STATE_CHANGE, + self.db, + None, + TaskStatus.READY, + ) + + task = await Task.get(1, self.project_id, self.db) + + query = """ + SELECT * FROM task_history + WHERE task_id = :task_id AND project_id = :project_id + LIMIT 1 + """ + task_history = await self.db.fetch_one( + query, {"task_id": 1, "project_id": self.project_id} + ) + + assert task.task_status == TaskStatus.READY.value + assert task_history is not None + + async def test_reset_task_clears_any_existing_locks(self): + """Test resetting a task clears any existing locks and sets it to READY status.""" + user_id = self.user.id + task_id = 9 + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, task_status, x, y, zoom, extra_properties) + VALUES (:id, :project_id, :task_status, :x, :y, :zoom, :extra_properties) + """, + { + "id": task_id, + "project_id": self.project_id, + "task_status": TaskStatus.MAPPED.value, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": None, + }, + ) + + await Task.reset_task(9, self.project_id, user_id, self.db) + task = await Task.get(9, self.project_id, self.db) + + # Ensure task status is now READY + assert task.task_status == TaskStatus.READY.value + + def test_cant_add_task_if_feature_geometry_is_invalid(self): + """Test that a task cannot be added if the feature geometry is invalid.""" + invalid_feature = geojson.loads( + '{"geometry": {"coordinates": [[[[-4.0237, 56.0904], [-3.9111, 56.1715],' + '[-3.8122, 56.098], [-4.0237]]]], "type": "MultiPolygon"}, "properties":' + '{"x": 2402, "y": 1736, "zoom": 12}, "type": "Feature"}' + ) + + with pytest.raises(InvalidGeoJson): + Task.from_geojson_feature(1, invalid_feature) + + def test_cant_add_task_if_feature_has_missing_properties(self): + """Test that a task cannot be added if the feature is missing required properties.""" + invalid_properties = geojson.loads( + '{"geometry": {"coordinates": [[[[-4.0237, 56.0904], [-3.9111, 56.1715],' + '[-3.8122, 56.098], [-4.0237, 56.0904]]]], "type": "MultiPolygon"},' + '"properties": {"x": 2402, "y": 1736}, "type": "Feature"}' + ) + + with pytest.raises(InvalidData): + Task.from_geojson_feature(1, invalid_properties) + + def test_can_add_task_if_feature_geometry_is_valid(self): + """Test that a task can be added if the feature geometry is valid.""" + valid_feature_collection = geojson.loads( + '{"geometry": {"coordinates": [[[[-4.0237, 56.0904],' + '[-3.9111, 56.1715], [-3.8122, 56.098], [-4.0237, 56.0904]]]], "type":' + '"MultiPolygon"}, "properties": {"x": 2402, "y": 1736, "zoom": 12, "isSquare": true}, "type":' + '"Feature"}' + ) + + task = Task.from_geojson_feature(1, valid_feature_collection) + assert task is not None # Ensures a valid task is created + + def test_cant_add_task_if_not_supplied_feature_type(self): + """Test that a task cannot be added if the feature type is missing or incorrect.""" + invalid_feature = geojson.MultiPolygon( + [[(2.38, 57.322), (23.194, -20.28), (-120.43, 19.15), (2.38, 10.33)]] + ) + + with pytest.raises(InvalidGeoJson): + Task.from_geojson_feature(1, invalid_feature) + + @pytest.mark.anyio + async def test_per_task_instructions_formatted_correctly(self): + """Test that task instructions format correctly using placeholders.""" + task_id = 10 + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, x, y, zoom, extra_properties) + VALUES (:id, :project_id, :x, :y, :zoom, :extra_properties) + """, + { + "id": task_id, + "project_id": self.project_id, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": None, + }, + ) + + instructions = await Task.format_per_task_instructions( + "Test URL is http://test.com/{x}/{y}/{z}", task_id, self.project_id, self.db + ) + + assert instructions == "Test URL is http://test.com/1/2/3" + + @pytest.mark.anyio + async def test_per_task_instructions_with_underscores_formatted_correctly(self): + """Test task instructions with underscore placeholders.""" + task_id = 11 + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, x, y, zoom, extra_properties) + VALUES (:id, :project_id, :x, :y, :zoom, :extra_properties) + """, + { + "id": task_id, + "project_id": self.project_id, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": None, + }, + ) + + instructions = await Task.format_per_task_instructions( + "Test URL is http://test.com/{x}_{y}_{z}", task_id, self.project_id, self.db + ) + + assert instructions == "Test URL is http://test.com/1_2_3" + + @pytest.mark.anyio + async def test_per_task_instructions_without_dynamic_url(self): + """Test task instructions when no dynamic URL is provided and the task is not splittable.""" + task_id = 12 + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, x, y, zoom, extra_properties) + VALUES (:id, :project_id, :x, :y, :zoom, :extra_properties) + """, + { + "id": task_id, + "project_id": self.project_id, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": None, + }, + ) + + instructions = await Task.format_per_task_instructions( + "Use map box", task_id, self.project_id, self.db + ) + + assert instructions == "Use map box" + + @pytest.mark.anyio + async def test_per_task_instructions_with_extra_properties(self): + """Test task instructions with extra properties.""" + task_id = 13 + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, x, y, zoom, extra_properties) + VALUES (:id, :project_id, :x, :y, :zoom, :extra_properties) + """, + { + "id": task_id, + "project_id": self.project_id, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": json.dumps({"foo": "bar"}), + }, + ) + + instructions = await Task.format_per_task_instructions( + "Foo is replaced by {foo}", task_id, self.project_id, self.db + ) + + assert instructions == "Foo is replaced by bar" + + async def test_record_auto_unlock_adds_autounlocked_action(self): + """Test that an auto-unlocked task gets the correct history action.""" + lock_duration = "02:00" + user_id = self.user.id + task_id = 19 + + await self.db.execute( + """ + INSERT INTO tasks (id, project_id, task_status, x, y, zoom, extra_properties, locked_by) + VALUES (:id, :project_id, :task_status, :x, :y, :zoom, :extra_properties, :locked_by) + """, + { + "id": task_id, + "project_id": self.project_id, + "task_status": TaskStatus.READY.value, + "x": 1, + "y": 2, + "zoom": 3, + "extra_properties": None, + "locked_by": user_id, + }, + ) + + await Task.record_auto_unlock(task_id, self.project_id, lock_duration, self.db) + + query = """ + SELECT * FROM task_history + WHERE task_id = :task_id AND project_id = :project_id + LIMIT 1 + """ + task_history = await self.db.fetch_one( + query, {"task_id": task_id, "project_id": self.project_id} + ) + assert task_history["action_text"] == lock_duration + assert task_history["user_id"] == user_id diff --git a/tests/api/unit/models/postgis/test_user.py b/tests/api/unit/models/postgis/test_user.py new file mode 100644 index 0000000000..fdde81dd9b --- /dev/null +++ b/tests/api/unit/models/postgis/test_user.py @@ -0,0 +1,93 @@ +import pytest + +from backend.models.postgis.user import MappingLevel, UserRole +from backend.services.users.user_service import UserService + + +@pytest.mark.anyio +class TestUser: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup test user asynchronously before each test.""" + assert db_connection_fixture is not None, "Database connection is not available" + test_user_data = { + "id": 12, + "role": UserRole.MAPPER.value, + "mapping_level": MappingLevel.BEGINNER.value, + "username": "Thinkwhere Test", + "email_address": "thinkwheretest@test.com", + "tasks_mapped": 0, + "tasks_validated": 0, + "tasks_invalidated": 0, + "is_email_verified": False, + "is_expert": False, + "default_editor": "ID", + "mentions_notifications": True, + "projects_comments_notifications": False, + "projects_notifications": True, + "tasks_notifications": True, + "tasks_comments_notifications": False, + "teams_announcement_notifications": True, + } + + query = """ + INSERT INTO users ( + id, role, mapping_level, username, email_address, + tasks_mapped, tasks_validated, tasks_invalidated, + is_email_verified, is_expert, default_editor, + mentions_notifications, projects_comments_notifications, + projects_notifications, tasks_notifications, + tasks_comments_notifications, teams_announcement_notifications + ) + VALUES ( + :id, :role, :mapping_level, :username, :email_address, + :tasks_mapped, :tasks_validated, :tasks_invalidated, + :is_email_verified, :is_expert, :default_editor, + :mentions_notifications, :projects_comments_notifications, + :projects_notifications, :tasks_notifications, + :tasks_comments_notifications, :teams_announcement_notifications + ) + ON CONFLICT (id) DO NOTHING + """ + await db_connection_fixture.execute(query, test_user_data) + + request.cls.db = db_connection_fixture + request.cls.test_user_id = test_user_data["id"] + + async def test_create_user(self): + """Test user creation with raw SQL.""" + query = "SELECT * FROM users WHERE id = :id" + result = await self.db.fetch_one(query, {"id": self.test_user_id}) + + assert result is not None, "User should exist in the database" + assert result["username"] == "Thinkwhere Test" + assert result["email_address"] == "thinkwheretest@test.com" + assert result["role"] == UserRole.MAPPER.value + assert result["mapping_level"] == MappingLevel.BEGINNER.value + assert result["default_editor"] == "ID" + + async def test_update_username(self): + """Test updating username using raw SQL.""" + new_username = "mrtest" + + user = await UserService.get_user_by_id(self.test_user_id, self.db) + await user.update_username(new_username, self.db) + + select_query = "SELECT username FROM users WHERE id = :id" + result = await self.db.fetch_one(select_query, {"id": self.test_user_id}) + + assert result["username"] == new_username + + async def test_update_picture_url(self): + """Test updating the user's profile picture URL.""" + test_picture_url = ( + "https://cdn.pixabay.com/photo/2022/04/29/08/50/desert-7162926_1280.jpg" + ) + + user = await UserService.get_user_by_id(self.test_user_id, self.db) + await user.update_picture_url(test_picture_url, self.db) + + select_query = "SELECT picture_url FROM users WHERE id = :id" + result = await self.db.fetch_one(select_query, {"id": self.test_user_id}) + + assert result["picture_url"] == test_picture_url diff --git a/tests/api/unit/services/grid/test_grid_service.py b/tests/api/unit/services/grid/test_grid_service.py new file mode 100644 index 0000000000..4fd822f225 --- /dev/null +++ b/tests/api/unit/services/grid/test_grid_service.py @@ -0,0 +1,231 @@ +import json +import pytest +import geojson +from shapely.geometry import shape + +from backend.models.dtos.grid_dto import GridDTO +from backend.models.dtos.project_dto import DraftProjectDTO +from backend.models.postgis.utils import InvalidGeoJson +from backend.services.grid.grid_service import GridService +from tests.api.helpers.test_helpers import get_canned_json + + +# Custom pytest fixture for deep almost equal assertion. Referenced from base.py of tests. +@pytest.fixture +def assert_deep_almost_equal(): + def _assert_deep_almost_equal(expected, actual, *args, places=7, **kwargs): + """ + Assert that two complex structures have almost equal contents. + + Compares lists, dicts and tuples recursively. Checks numeric values + using pytest's pytest.approx and checks all other values with assert. + """ + kwargs.pop("__trace", "ROOT") + if ( + hasattr(expected, "__geo_interface__") + and hasattr(actual, "__geo_interface__") + and expected.__geo_interface__["type"] == actual.__geo_interface__["type"] + and expected.__geo_interface__["type"] + not in ["Feature", "FeatureCollection"] + ): + shape_expected = shape(expected) + shape_actual = shape(actual) + assert shape_expected.equals(shape_actual) + elif isinstance(expected, (int, float, complex)): + if places is not None: + # Calculate acceptable delta based on places + delta = 0.5 * 10 ** (-places) + assert expected == pytest.approx(actual, abs=delta) + else: + assert expected == pytest.approx(actual) + elif isinstance(expected, (list, tuple)): + assert len(expected) == len(actual) + for index in range(len(expected)): + v1, v2 = expected[index], actual[index] + _assert_deep_almost_equal( + v1, v2, places=places, __trace=repr(index), *args, **kwargs + ) + elif isinstance(expected, dict): + assert set(expected) == set(actual) + for key in expected: + _assert_deep_almost_equal( + expected[key], + actual[key], + places=places, + __trace=repr(key), + *args, + **kwargs, + ) + else: + assert expected == actual + + return _assert_deep_almost_equal + + +@pytest.mark.anyio +class TestGridService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup required test data and configure geojson precision""" + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + # Store the default precision and set to 7 for tests + self.default_precision = geojson.geometry.DEFAULT_PRECISION + geojson.geometry.DEFAULT_PRECISION = 7 + + # Clean up after test + yield + + # Restore default precision + geojson.geometry.DEFAULT_PRECISION = self.default_precision + + async def test_feature_collection_to_multi_polygon_dissolve( + self, assert_deep_almost_equal + ): + # Arrange + grid_json = get_canned_json("test_grid.json") + grid_dto = GridDTO(**grid_json) + aoi_geojson = geojson.loads(json.dumps(grid_dto.area_of_interest)) + expected = geojson.loads( + json.dumps(get_canned_json("multi_polygon_dissolved.json")) + ) + + # Act + result = GridService.merge_to_multi_polygon(aoi_geojson, True) + + # Assert + assert_deep_almost_equal(expected, result) + + async def test_feature_collection_to_multi_polygon_nodissolve(self): + # Arrange + grid_json = get_canned_json("test_grid.json") + grid_dto = GridDTO(**grid_json) + expected = geojson.loads(json.dumps(get_canned_json("multi_polygon.json"))) + aoi_geojson = geojson.loads(json.dumps(grid_dto.area_of_interest)) + + # Act + result = GridService.merge_to_multi_polygon(aoi_geojson, False) + + # Assert + assert str(expected) == str(result) + + async def test_trim_grid_to_aoi_clip(self, assert_deep_almost_equal): + # Arrange + grid_json = get_canned_json("test_grid.json") + grid_dto = GridDTO(**grid_json) + expected = geojson.loads( + json.dumps(get_canned_json("clipped_feature_collection.json")) + ) + grid_dto.clip_to_aoi = True + + # Act + result = GridService.trim_grid_to_aoi(grid_dto) + + # Assert + assert_deep_almost_equal(expected, result, places=6) + + async def test_trim_grid_to_aoi_noclip(self, assert_deep_almost_equal): + # Arrange + grid_json = get_canned_json("test_grid.json") + grid_dto = GridDTO(**grid_json) + grid_dto.clip_to_aoi = False + + expected = geojson.loads(json.dumps(get_canned_json("feature_collection.json"))) + + # Act + result = GridService.trim_grid_to_aoi(grid_dto) + + # Assert + assert_deep_almost_equal(expected, result) + + async def test_tasks_from_aoi_features(self): + # Arrange + grid_json = get_canned_json("test_arbitrary.json") + grid_dto = GridDTO.construct(**grid_json) # Bypass required fields validation. + expected = geojson.loads( + json.dumps(get_canned_json("tasks_from_aoi_features.json")) + ) + + # Act + result = GridService.tasks_from_aoi_features(grid_dto.area_of_interest) + + # Assert + assert str(expected) == str(result) + + async def test_feature_collection_multi_polygon_with_zcoord_nodissolve(self): + # Arrange + project_json = get_canned_json("canned_kml_project.json") + project_dto = DraftProjectDTO(**project_json) + expected = geojson.loads(json.dumps(get_canned_json("2d_multi_polygon.json"))) + aoi_geojson = geojson.loads(json.dumps(project_dto.area_of_interest)) + + # Act + result = GridService.merge_to_multi_polygon(aoi_geojson, dissolve=False) + + # Assert + assert str(expected) == str(result) + + async def test_feature_collection_multi_polygon_with_zcoord_dissolve(self): + # Arrange + project_json = get_canned_json("canned_kml_project.json") + project_dto = DraftProjectDTO(**project_json) + expected = geojson.loads(json.dumps(get_canned_json("2d_multi_polygon.json"))) + aoi_geojson = geojson.loads(json.dumps(project_dto.area_of_interest)) + + # Act + result = GridService.merge_to_multi_polygon(aoi_geojson, dissolve=True) + + # Assert + assert str(expected) == str(result) + + async def test_raises_InvalidGeoJson_when_geometry_is_linestring(self): + # Arrange + grid_json = get_canned_json("CHAI-Escuintla-West2.json") + grid_dto = GridDTO(**grid_json) + grid_dto.clip_to_aoi = True + + # Act / Assert + with pytest.raises(InvalidGeoJson): + GridService.merge_to_multi_polygon(grid_dto.area_of_interest, dissolve=True) + + async def test_cant_create_aoi_with_non_multipolygon_type(self): + # Arrange + bad_geom = geojson.Polygon( + [[(2.38, 57.322), (23.194, -20.28), (-120.43, 19.15), (2.38, 57.322)]] + ) + bad_feature = geojson.Feature(geometry=bad_geom) + + # Act / Assert + with pytest.raises(InvalidGeoJson): + # Only geometries of type MultiPolygon are valid + GridService.merge_to_multi_polygon( + geojson.dumps(bad_feature), dissolve=True + ) + + async def test_cant_create_aoi_with_invalid_multipolygon(self): + # Arrange + bad_multipolygon = geojson.MultiPolygon( + [[(2.38, 57.322), (23.194, -20.28), (-120.43, 19.15), (2.38)]] + ) + bad_feature = geojson.Feature(geometry=bad_multipolygon) + bad_feature_collection = geojson.FeatureCollection([bad_feature]) + + # Act / Assert + with pytest.raises(InvalidGeoJson): + # Only geometries of type MultiPolygon are valid + GridService.merge_to_multi_polygon( + geojson.dumps(bad_feature_collection), dissolve=True + ) + + async def test_to_shapely_geometries(self): + # Arrange + grid_json = get_canned_json("test_arbitrary.json") + grid_dto = GridDTO.construct(**grid_json) # Bypass required fields validation. + grid_geojson = json.dumps(grid_dto.area_of_interest) + + # Act + features = GridService._to_shapely_geometries(grid_geojson) + + # Assert + assert len(features) > 0 diff --git a/tests/api/unit/services/messaging/test_messaging_service.py b/tests/api/unit/services/messaging/test_messaging_service.py new file mode 100644 index 0000000000..14572e74b5 --- /dev/null +++ b/tests/api/unit/services/messaging/test_messaging_service.py @@ -0,0 +1,108 @@ +import pytest +from unittest.mock import patch, AsyncMock + +from backend.models.postgis.message import Message +from backend.services.messaging.message_service import MessageService + + +MESSAGE_TYPES = "3,2,1" +TEST_USER_ID = 111111111 + + +@pytest.mark.anyio +class TestMessagingService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Setup required test data""" + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + async def test_message_service_generates_correct_task_link(self): + # Act + link = MessageService.get_task_link(1, 1, "http://test.com") + + # Assert + assert ( + link + == 'Task 1' + ) + + async def test_message_service_generates_highlighted_task_link(self): + # Act + link = MessageService.get_task_link(1, 1, "example.com", highlight=True) + + # Assert + assert ( + link + == 'Task 1' + ) + + async def test_message_service_generates_correct_chat_link(self): + # Act + link = MessageService.get_project_link( + 1, "TEST_PROJECT", "http://test.com", include_chat_section=True + ) + + assert ( + link + == 'TEST_PROJECT #1' + ) + + link = MessageService.get_project_link( + 1, "TEST_PROJECT", "http://test.com", highlight=True + ) + + assert ( + link + == 'TEST_PROJECT #1' + ) + + @patch.object(Message, "delete_multiple_messages") + async def test_delete_multiple_messages(self, mock_delete_multiple_messages): + """Test that the delete_multiple_messages method calls the model method""" + # Configure the mock to be awaitable + mock_delete_multiple_messages.return_value = AsyncMock() + + # Act + await MessageService.delete_multiple_messages([1, 2, 3], 1, self.db) + + # Assert + mock_delete_multiple_messages.assert_called_once() + + @patch.object(Message, "delete_all_messages") + async def test_delete_all_messages(self, mock_delete_all_messages): + """Test that the delete_all_messages method calls the model method""" + # Configure the mock to be awaitable + mock_delete_all_messages.return_value = AsyncMock() + + # Act + await MessageService.delete_all_messages(1, self.db, MESSAGE_TYPES) + + # Assert + message_type = list(map(int, list(MESSAGE_TYPES.split(",")))) + mock_delete_all_messages.assert_called_with(1, self.db, message_type) + + @patch.object(Message, "mark_multiple_messages_read") + async def test_mark_multiple_messages_read(self, mock_mark_multiple_messages_read): + """Test that the mark_multiple_messages_read method calls the model method""" + # Configure the mock to be awaitable + mock_mark_multiple_messages_read.return_value = AsyncMock() + + # Act + await MessageService.mark_multiple_messages_read([1, 2, 3], 1, self.db) + + # Assert + mock_mark_multiple_messages_read.assert_called_once() + + @patch.object(Message, "mark_all_messages_read") + async def test_mark_all_messages_read(self, mock_mark_all_messages_read): + """Test that the mark_all_messages_read method calls the model method""" + # Configure the mock to be awaitable + mock_mark_all_messages_read.return_value = AsyncMock() + + # Act + await MessageService.mark_all_messages_read(1, self.db, MESSAGE_TYPES) + + # Assert + message_type = list(map(int, list(MESSAGE_TYPES.split(",")))) + mock_mark_all_messages_read.assert_called_with(1, self.db, message_type) diff --git a/tests/api/unit/services/messaging/test_template_service.py b/tests/api/unit/services/messaging/test_template_service.py new file mode 100644 index 0000000000..0f4b9df06f --- /dev/null +++ b/tests/api/unit/services/messaging/test_template_service.py @@ -0,0 +1,55 @@ +import pytest +from backend.services.messaging.template_service import ( + clean_html, + format_username_link, + get_template, + template_var_replacing, +) +from backend.config import test_settings as settings + + +@pytest.mark.anyio +class TestTemplateService: + def test_variable_replacing(self): + # Act + values = {"USERNAME": "USERNAME", "VERIFICATION_LINK": "VERIFICATION_LINK"} + content = get_template("email_verification_en.html", values) + replace_list = [ + ["USERNAME", "test_user"], + ["VERIFICATION_LINK", "http://localhost:30/verify.html#1234"], + [settings.ORG_CODE, "HOT"], + [settings.ORG_NAME, "Organization Test"], + ] + processed_content = template_var_replacing(content, replace_list) + + # Assert + assert "test_user" in processed_content + assert "http://localhost:30/verify.html#1234" in processed_content + assert "HOT" in processed_content + assert "Organization Test" in processed_content + + assert "[USERNAME]" not in processed_content + assert "[VERIFICATION_LINK]" not in processed_content + assert "[ORG_CODE]" not in processed_content + assert "[ORG_NAME]" not in processed_content + + def test_clean_html(self): + result = clean_html( + 'Welcome to Tasking Manager!' + ) + assert result == "Welcome to Tasking Manager!" + + def test_format_username_link(self): + base_url = settings.APP_BASE_URL + + result = format_username_link("try @[yo] @[us2]! [t](http://a.c)") + expected = ( + f'try @yo' + f' @us2! [t](http://a.c)' + ) + assert result == expected + + result = format_username_link( + "testing @user! Write me at i@we.com [test](http://link.com)" + ) + assert result == "testing @user! Write me at i@we.com [test](http://link.com)" diff --git a/tests/api/unit/services/test_mapping_service.py b/tests/api/unit/services/test_mapping_service.py new file mode 100644 index 0000000000..b26cd01a4f --- /dev/null +++ b/tests/api/unit/services/test_mapping_service.py @@ -0,0 +1,295 @@ +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from backend.services.mapping_service import ( + MappingService, + MappingServiceError, + UserLicenseError, +) +from backend.models.postgis.task import Task, TaskStatus, TaskHistory, TaskAction +from backend.models.dtos.mapping_dto import LockTaskDTO, MappedTaskDTO +from backend.services.project_service import ProjectService, MappingNotAllowed +from backend.exceptions import NotFound +from tests.api.helpers.test_helpers import ( + create_canned_project, + create_canned_user, + return_canned_user, +) + + +@pytest.mark.anyio +class TestMappingService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + test_user = MagicMock() + test_user.id = 123456 + test_user.username = "Thinkwhere" + + self.task_stub = Task() + self.task_stub.id = 1 + self.task_stub.project_id = 1 + self.task_stub.task_status = 0 + self.task_stub.locked_by = 123456 + self.task_stub.lock_holder = test_user + + self.lock_task_dto = LockTaskDTO(user_id=123456, task_id=1, project_id=1) + + self.mapped_task_dto = MappedTaskDTO( + user_id=123456, task_id=1, project_id=1, status=TaskStatus.MAPPED.name + ) + + @patch.object(Task, "get") + async def test_get_task_raises_error_if_task_not_found(self, mock_task): + mock_task.return_value = None + + with pytest.raises(NotFound): + await MappingService.get_task(12, 12, self.db) + + @patch.object(MappingService, "get_task") + async def test_lock_task_for_mapping_raises_error_if_task_in_invalid_state( + self, mock_task + ): + # Arrange + self.task_stub.task_status = TaskStatus.MAPPED.value + self.task_stub.locked_by = None + mock_task.return_value = self.task_stub + + # Act / Assert + with pytest.raises(MappingServiceError): + await MappingService.lock_task_for_mapping(self.lock_task_dto, self.db) + + @patch.object(ProjectService, "is_user_permitted_to_map") + @patch.object(MappingService, "get_task") + async def test_lock_task_for_mapping_raises_error_if_user_already_has_locked_task( + self, mock_task, mock_project + ): + # Arrange + self.task_stub.locked_by = None + mock_task.return_value = self.task_stub + mock_project.return_value = ( + False, + MappingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED, + ) + + # Act / Assert + with pytest.raises(MappingServiceError): + await MappingService.lock_task_for_mapping(self.lock_task_dto, self.db) + + @patch.object(ProjectService, "is_user_permitted_to_map") + @patch.object(MappingService, "get_task") + async def test_lock_task_for_mapping_raises_error_if_user_has_not_accepted_license( + self, mock_task, mock_project + ): + # Arrange + self.task_stub.locked_by = None + mock_task.return_value = self.task_stub + mock_project.return_value = (False, MappingNotAllowed.USER_NOT_ACCEPTED_LICENSE) + + # Act / Assert + with pytest.raises(UserLicenseError): + await MappingService.lock_task_for_mapping(self.lock_task_dto, self.db) + + async def test_unlock_of_not_locked_for_mapping_raises_error(self): + # Arrange + project, user, project_id = await create_canned_project(self.db) + query = """ + SELECT * FROM tasks + WHERE project_id = :project_id AND task_status = :task_status + """ + task = await self.db.fetch_one( + query=query, + values={"project_id": project_id, "task_status": TaskStatus.READY.value}, + ) + mapped_task_dto = MappedTaskDTO( + user_id=user.id, + task_id=task.id, + project_id=project_id, + status=TaskStatus.MAPPED.name, + ) + + # Act / Assert + with pytest.raises(MappingServiceError): + await MappingService.unlock_task_after_mapping( + mapped_task_dto, self.db, MagicMock() + ) + + @patch.object(ProjectService, "is_user_permitted_to_map") + async def test_cant_unlock_a_task_you_dont_own(self, mock_mapping_permitted): + # Arrange + project, user, project_id = await create_canned_project(self.db) + test_user = return_canned_user("TEST", 12) + test_user = await create_canned_user(self.db, test_user) + query = """ + SELECT * FROM tasks + WHERE project_id = :project_id AND task_status = :task_status + """ + task = await self.db.fetch_one( + query=query, + values={"project_id": project_id, "task_status": TaskStatus.READY.value}, + ) + lock_task_dto = LockTaskDTO( + project_id=project_id, task_id=task.id, user_id=test_user.id + ) + mock_mapping_permitted.return_value = True, "User allowed to map" + await MappingService.lock_task_for_mapping(lock_task_dto, self.db) + + mapped_task_dto = MappedTaskDTO( + user_id=user.id, + task_id=task.id, + project_id=project_id, + status=TaskStatus.MAPPED.name, + ) + + # Act / Assert + with pytest.raises(MappingServiceError): + await MappingService.unlock_task_after_mapping( + mapped_task_dto, self.db, MagicMock() + ) + + @patch.object(MappingService, "get_task_locked_by_user") + async def test_if_new_state_not_acceptable_raise_error(self, mock_task): + # Arrange + self.task_stub.task_status = TaskStatus.LOCKED_FOR_MAPPING.value + mock_task.return_value = self.task_stub + self.mapped_task_dto.status = TaskStatus.LOCKED_FOR_VALIDATION.name + + # Act / Assert + with pytest.raises(MappingServiceError): + await MappingService.unlock_task_after_mapping( + self.mapped_task_dto, self.db, MagicMock() + ) + + @patch.object(ProjectService, "is_user_permitted_to_map") + async def test_unlock_with_comment_sets_history(self, mock_mapping_permitted): + # Arrange + project, user, project_id = await create_canned_project(self.db) + query = """ + SELECT * FROM tasks + WHERE project_id = :project_id AND task_status = :task_status + """ + task = await self.db.fetch_one( + query=query, + values={"project_id": project_id, "task_status": TaskStatus.READY.value}, + ) + lock_task_dto = LockTaskDTO( + project_id=project_id, task_id=task.id, user_id=user.id + ) + mock_mapping_permitted.return_value = True, "User allowed to map" + await MappingService.lock_task_for_mapping(lock_task_dto, self.db) + mapped_task_dto = MappedTaskDTO( + user_id=user.id, + task_id=task.id, + project_id=project_id, + status=TaskStatus.MAPPED.name, + comment="Test comment", + ) + + # Act + await MappingService.unlock_task_after_mapping( + mapped_task_dto, self.db, AsyncMock() + ) + query = """ + SELECT * FROM task_history + WHERE project_id = :project_id AND task_id = :task_id + """ + task_history = await self.db.fetch_all( + query=query, values={"project_id": project_id, "task_id": task.id} + ) + + # Assert + assert task_history[1].action == TaskAction.COMMENT.name + assert task_history[1].action_text == "Test comment" + + @patch.object(ProjectService, "is_user_permitted_to_map") + async def test_unlock_with_status_change_sets_history(self, mock_mapping_permitted): + # Arrange + project, user, project_id = await create_canned_project(self.db) + + # Select a READY task + query = """ + SELECT * FROM tasks + WHERE project_id = :project_id AND task_status = :task_status + """ + task = await self.db.fetch_one( + query=query, + values={"project_id": project_id, "task_status": TaskStatus.READY.value}, + ) + + # Lock the task + lock_task_dto = LockTaskDTO( + project_id=project_id, task_id=task.id, user_id=user.id + ) + mock_mapping_permitted.return_value = True, "User allowed to map" + await MappingService.lock_task_for_mapping(lock_task_dto, self.db) + + # Prepare mapped task DTO without a comment, just a status change + mapped_task_dto = MappedTaskDTO( + user_id=user.id, + task_id=task.id, + project_id=project_id, + status=TaskStatus.MAPPED.name, + ) + + # Act + test_task = await MappingService.unlock_task_after_mapping( + mapped_task_dto, self.db, AsyncMock() + ) + + query = """ + SELECT * FROM task_history + WHERE project_id = :project_id AND task_id = :task_id + ORDER BY id + """ + task_history = await self.db.fetch_all( + query=query, values={"project_id": project_id, "task_id": task.id} + ) + + # Assert + assert test_task.task_status == TaskStatus.MAPPED.name + assert task_history[0].action == TaskAction.LOCKED_FOR_MAPPING.name + assert task_history[1].action == TaskAction.STATE_CHANGE.name + assert task_history[1].action_text == TaskStatus.MAPPED.name + + @patch.object(ProjectService, "is_user_permitted_to_validate") + @patch.object(TaskHistory, "get_last_action") + async def test_task_is_undoable_if_last_change_made_by_you( + self, last_action, mock_project + ): + # Arrange + task_history = TaskHistory(1, 1, 1) + task_history.user_id = 1 + last_action.return_value = task_history + + task = Task() + task.task_status = TaskStatus.MAPPED.value + task.mapped_by = 1 + + # Act + mock_project.return_value = (True, None) + is_undoable = await MappingService._is_task_undoable(1, task, self.db) + + # Assert + assert is_undoable + + @patch.object(ProjectService, "is_user_permitted_to_validate") + @patch.object(TaskHistory, "get_last_action") + async def test_task_is_not_undoable_if_last_change_not_made_by_you( + self, last_action, mock_project + ): + # Arrange + task_history = TaskHistory(1, 1, 1) + task_history.user_id = 2 + last_action.return_value = task_history + + task = Task() + task.task_status = TaskStatus.MAPPED.value + task.mapped_by = 1 + + # Act + mock_project.return_value = (False, None) + is_undoable = await MappingService._is_task_undoable(1, task, self.db) + + # Assert + assert not is_undoable diff --git a/tests/api/unit/services/test_organisation_service.py b/tests/api/unit/services/test_organisation_service.py new file mode 100644 index 0000000000..97463abcec --- /dev/null +++ b/tests/api/unit/services/test_organisation_service.py @@ -0,0 +1,90 @@ +import pytest +from backend.models.postgis.statuses import UserRole +from backend.services.organisation_service import NotFound, OrganisationService +from tests.api.helpers.test_helpers import ( + create_canned_organisation, + create_canned_user, + return_canned_user, +) + + +@pytest.mark.anyio +class TestOrganisationService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.test_org = await create_canned_organisation(db_connection_fixture) + request.cls.test_user = await create_canned_user(db_connection_fixture) + request.cls.db = db_connection_fixture + + assert self.test_org is not None, "Failed to create test organisation" + assert self.test_user is not None, "Failed to create test user" + + async def test_get_organisation_by_id_returns_organisation(self): + # Act + result_org = await OrganisationService.get_organisation_by_id( + self.test_org.id, self.db + ) + # Assert + assert self.test_org.id == result_org.organisation_id + assert self.test_org.name == result_org.name + assert self.test_org.slug == result_org.slug + + async def test_get_organisation_by_id_raises_error_if_organisation_not_found(self): + # Act/Assert + with pytest.raises(NotFound): + await OrganisationService.get_organisation_by_id(123, self.db) + + async def test_organisation_managed_by_user_as_dto(self): + # Arrange + self.test_org.managers = [self.test_user] + + # Act + organisations_dto = ( + await OrganisationService.get_organisations_managed_by_user_as_dto( + self.test_user.id, self.db + ) + ) + + # Assert + assert organisations_dto.organisations == [] + + async def test_organisation_managed_by_user(self): + # Assign the user as a manager + await self.db.execute( + """ + INSERT INTO organisation_managers (organisation_id, user_id) + VALUES (:organisation_id, :user_id) + """, + {"organisation_id": self.test_org.id, "user_id": self.test_user.id}, + ) + + # Act + organisations = await OrganisationService.get_organisations_managed_by_user( + self.test_user.id, self.db + ) + + # Assert + assert self.test_org.name == organisations[0].name + + async def test_organisation_managed_by_user_returns_all_organisations_for_admin( + self, + ): + # Arrange + admin_user = return_canned_user() + admin_user.role = UserRole.ADMIN.value + await self.db.execute( + """ + UPDATE users SET role = :role WHERE id = :user_id + """, + {"role": admin_user.role, "user_id": admin_user.id}, + ) + + # Act + organisations = await OrganisationService.get_organisations_managed_by_user( + admin_user.id, self.db + ) + + # Assert + assert self.test_org.name == organisations[0].name diff --git a/tests/api/unit/services/test_project_admin_service.py b/tests/api/unit/services/test_project_admin_service.py new file mode 100644 index 0000000000..97db193220 --- /dev/null +++ b/tests/api/unit/services/test_project_admin_service.py @@ -0,0 +1,172 @@ +from backend.models.postgis.statuses import TaskStatus +import pytest +import json +from unittest.mock import AsyncMock + +from backend.models.dtos.project_dto import ProjectInfoDTO +from backend.models.postgis.task import Task +from backend.services.project_admin_service import ( + InvalidGeoJson, + NotFound, + Project, + ProjectAdminService, + ProjectAdminServiceError, +) +from tests.api.helpers.test_helpers import create_canned_project + + +@pytest.mark.anyio +class TestProjectAdminService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """ + Fixture to initialize database connection and set up test data. + Ensures DB connection is available before running tests. + """ + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.db = db_connection_fixture + + async def test_cant_add_tasks_if_geojson_not_feature_collection(self): + # Arrange + invalid_feature = json.dumps( + { + "coordinates": [ + [ + [ + [-4.0237, 56.0904], + [-3.9111, 56.1715], + [-3.8122, 56.098], + [-4.0237, 56.0904], + ] + ] + ], + "type": "MultiPolygon", + } + ) + + # Act / Assert + with pytest.raises(InvalidGeoJson): + await ProjectAdminService._attach_tasks_to_project( + AsyncMock(), invalid_feature, self.db + ) + + async def test_valid_geo_json_attaches_task_to_project(self): + # Arrange + valid_feature_collection = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-4.0237, 56.0904], + [-3.9111, 56.1715], + [-3.8122, 56.098], + [-4.0237, 56.0904], + ] + ] + ], + }, + "properties": {"x": 2402, "y": 1736, "zoom": 12, "isSquare": True}, + } + ], + } + + test_project = Project() + + # Act + await ProjectAdminService._attach_tasks_to_project( + test_project, valid_feature_collection, self.db + ) + # Assert + assert ( + test_project.tasks.count() == 1 + ), "One task should have been attached to project" + + async def test_get_raises_error_if_not_found(self): + # Act / Assert + with pytest.raises(NotFound): + await ProjectAdminService._get_project_by_id(12, self.db) + + async def test_complete_default_locale_is_valid(self): + # Arrange + locales = [ + ProjectInfoDTO( + locale="en", + name="Test", + description="Test Desc", + short_description="Short Desc", + instructions="Instruct", + ) + ] + + # Act + is_valid = ProjectAdminService._validate_default_locale("en", locales) + + # Assert + assert is_valid, "Complete default locale should be valid" + + async def test_complete_default_locale_raises_error_if_incomplete(self): + # Arrange + locales = [ + ProjectInfoDTO( + locale="en", + name="Test", + description="Test Desc", + short_description="Short Desc", + ) + ] + + # Act / Assert + with pytest.raises(ProjectAdminServiceError): + await ProjectAdminService._validate_default_locale("en", locales) + + async def test_complete_default_locale_raises_error_if_default_locale_not_found( + self, + ): + # Arrange + locales = [ + ProjectInfoDTO( + locale="en", + name="Test", + description="Test Desc", + short_description="Short Desc", + ) + ] + + # Act / Assert + with pytest.raises(ProjectAdminServiceError): + await ProjectAdminService._validate_default_locale("it", locales) + + async def test_attempting_to_attach_non_existant_license_raises_error(self): + # Act / Assert + with pytest.raises(ProjectAdminServiceError): + await ProjectAdminService._validate_imagery_licence(1, self.db) + + async def test_reset_all_tasks(self): + test_project, test_user, project_id = await create_canned_project(self.db) + # Act + await ProjectAdminService.reset_all_tasks(project_id, test_user.id, self.db) + # Assert + for test_task in test_project.tasks: + task = await Task.get(test_task.id, project_id, self.db) + task_history = await Task.get_task_history( + test_task.id, project_id, self.db + ) + assert task.task_status == TaskStatus.READY.value + if task_history: + assert task_history[0].action_text == "Task reset" + query = """ + SELECT id, tasks_mapped, tasks_validated + FROM projects + WHERE id = :project_id + """ + values = {"project_id": project_id} + row = await self.db.fetch_one(query=query, values=values) + + assert row.tasks_mapped == 0 + assert row.tasks_validated == 0 diff --git a/tests/api/unit/services/test_project_search_service.py b/tests/api/unit/services/test_project_search_service.py new file mode 100644 index 0000000000..4b87e87bd6 --- /dev/null +++ b/tests/api/unit/services/test_project_search_service.py @@ -0,0 +1,52 @@ +import pytest + +from backend.models.dtos.project_dto import ProjectSearchDTO +from backend.models.postgis.statuses import ProjectDifficulty, ProjectStatus +from backend.services.project_search_service import ProjectSearchService +from tests.api.helpers.test_helpers import create_canned_project + + +@pytest.mark.anyio +class TestProjectService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.db = db_connection_fixture + ( + request.cls.test_project, + request.cls.test_user, + request.cls.project_id, + ) = await create_canned_project(db_connection_fixture) + + # Update test project properties + await db_connection_fixture.execute( + """ + UPDATE projects SET difficulty = :difficulty, status = :status WHERE id = :id + """, + { + "difficulty": ProjectDifficulty.EASY.value, + "status": ProjectStatus.PUBLISHED.value, + "id": self.project_id, + }, + ) + + async def test_project_search_returns_results(self): + # Arrange + search_dto = ProjectSearchDTO( + difficulty="EASY", + project_statuses=["PUBLISHED"], + order_by="priority", + order_by_type="DESC", + page=1, + ) + + # Act + project_search_dto = await ProjectSearchService.search_projects( + search_dto, self.test_user, self.db + ) + + # Assert + assert project_search_dto is not None + assert len(project_search_dto.results) > 0 + assert any(p.project_id == self.project_id for p in project_search_dto.results) diff --git a/tests/api/unit/services/test_project_service.py b/tests/api/unit/services/test_project_service.py new file mode 100644 index 0000000000..aa87db5964 --- /dev/null +++ b/tests/api/unit/services/test_project_service.py @@ -0,0 +1,405 @@ +from backend.models.postgis.project_info import ProjectInfo +import pytest +from unittest.mock import AsyncMock, patch +from fastapi import HTTPException +from backend.models.dtos.project_dto import LockedTasksForUser +from backend.models.postgis.task import Task +from backend.services.messaging.smtp_service import SMTPService +from backend.services.project_service import ( + MappingLevel, + MappingNotAllowed, + Project, + ProjectAdminService, + ProjectService, + ProjectStatus, + UserService, + ValidatingNotAllowed, +) + + +@pytest.mark.anyio +class TestProjectService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + @patch.object(Project, "get") + async def test_project_service_raises_error_if_project_not_found( + self, mock_project + ): + # Arrange + mock_project.return_value = None + + # Act / Assert + with pytest.raises(HTTPException): + await ProjectService.get_project_by_id(123, self.db) + + @patch.object(UserService, "get_mapping_level") + async def test_user_not_allowed_to_map_if_level_enforced(self, mock_level): + # Arrange + mock_level.return_value = MappingLevel.BEGINNER + + # Act + allowed = await ProjectService._is_user_intermediate_or_advanced(1, self.db) + + # Assert + assert not allowed + + @patch.object(UserService, "get_mapping_level") + async def test_user_is_allowed_to_map_if_level_enforced(self, mock_level): + # Arrange + mock_level.return_value = MappingLevel.ADVANCED + + # Act + allowed = await ProjectService._is_user_intermediate_or_advanced(1, self.db) + + # Assert + assert allowed + + @patch.object(ProjectAdminService, "is_user_action_permitted_on_project") + @patch.object(UserService, "is_user_blocked") + @patch.object(UserService, "has_user_accepted_license") + @patch.object(Task, "get_locked_tasks_for_user") + @patch.object(ProjectService, "get_project_by_id") + async def test_user_allowed_to_map( + self, + mock_project, + mock_user_tasks, + mock_user_license, + mock_user_blocked, + mock_user_is_action_permitted, + ): + # Arrange - Mock project + stub_project = Project() + stub_project.status = ProjectStatus.PUBLISHED.value + stub_project.license_id = 11 + mock_project.return_value = stub_project + + # Admin user related + mock_user_is_action_permitted.return_value = True + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[]) + mock_user_license.return_value = True + mock_user_blocked.return_value = False + + # Act / Assert - Admin allowed to map + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert allowed + assert reason == "User allowed to map" + + # Admin not accepted license should fail + mock_user_license.return_value = False + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert not allowed + assert reason == MappingNotAllowed.USER_NOT_ACCEPTED_LICENSE + + # Admin with already locked tasks should fail + mock_user_license.return_value = True + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[1]) + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert not allowed + assert reason == MappingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED + + # Admin can access draft projects + stub_project.status = ProjectStatus.DRAFT.value + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[]) + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert allowed + assert reason == "User allowed to map" + + # Mappers + mock_user_is_action_permitted.return_value = False + mock_user_blocked.return_value = False + + # Cannot access unpublished project + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert not allowed + assert reason == MappingNotAllowed.PROJECT_NOT_PUBLISHED + + # Mappers not accepted license should fail + mock_user_license.return_value = False + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert not allowed + assert reason == MappingNotAllowed.USER_NOT_ACCEPTED_LICENSE + + # Blocked user + mock_user_blocked.return_value = True + allowed, reason = await ProjectService.is_user_permitted_to_map(1, 1, self.db) + assert not allowed + assert reason == MappingNotAllowed.USER_NOT_ON_ALLOWED_LIST + + @patch.object(ProjectAdminService, "is_user_action_permitted_on_project") + @patch.object(UserService, "is_user_blocked") + @patch.object(UserService, "has_user_accepted_license") + @patch.object(Task, "get_locked_tasks_for_user") + @patch.object(ProjectService, "get_project_by_id") + async def test_user_permitted_to_validate( + self, + mock_project, + mock_user_tasks, + mock_user_license, + mock_user_blocked, + mock_user_is_action_permitted, + ): + # Arrange - Mock project + stub_project = Project() + stub_project.status = ProjectStatus.PUBLISHED.value + stub_project.license_id = 1 + mock_project.return_value = stub_project + + # Admin user related + mock_user_is_action_permitted.return_value = True + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[]) + mock_user_license.return_value = True + mock_user_blocked.return_value = False + + # Act / Assert - Admin allowed to validate + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert allowed + assert reason == "User allowed to validate" + + # Admin not accepted license should fail + mock_user_license.return_value = False + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert not allowed + assert reason == ValidatingNotAllowed.USER_NOT_ACCEPTED_LICENSE + + # Admin with already locked tasks should fail + mock_user_license.return_value = True + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[1]) + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert not allowed + assert reason == ValidatingNotAllowed.USER_ALREADY_HAS_TASK_LOCKED + + # Blocked user + mock_user_blocked.return_value = True + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert not allowed + assert reason == ValidatingNotAllowed.USER_NOT_ON_ALLOWED_LIST + + # Unpublished project + stub_project.status = ProjectStatus.DRAFT.value + mock_user_blocked.return_value = False + mock_user_is_action_permitted.return_value = False + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert not allowed + assert reason == ValidatingNotAllowed.PROJECT_NOT_PUBLISHED + + # Admin can access draft projects + stub_project.status = ProjectStatus.DRAFT.value + mock_user_blocked.return_value = False + mock_user_is_action_permitted.return_value = True + mock_user_tasks.return_value = LockedTasksForUser(locked_tasks=[]) + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert allowed + assert reason == "User allowed to validate" + + # Mappers not accepted license should fail + mock_user_blocked.return_value = False + mock_user_is_action_permitted.return_value = False + mock_user_license.return_value = False + allowed, reason = await ProjectService.is_user_permitted_to_validate( + 1, 1, self.db + ) + assert not allowed + assert reason == ValidatingNotAllowed.USER_NOT_ACCEPTED_LICENSE + + @patch("backend.services.project_service.db_connection.database.connection") + @patch.object(SMTPService, "send_email_to_contributors_on_project_progress") + @patch.object(Project, "calculate_tasks_percent") + @patch.object(ProjectInfo, "get_dto_for_locale") + @patch.object(ProjectService, "get_project_by_id") + @patch("backend.services.project_service.get_settings") + async def test_send_email_on_project_progress_sends_email_on_fifty_percent_progress( + self, + mock_settings, + mock_project, + mock_project_info, + mock_project_completion, + mock_send_email, + mock_db_connection, + ): + # Arrange + project = Project() + project.progress_email_sent = False + project.tasks_mapped = 50 + project.tasks_validated = 0 + project.total_tasks = 100 + project.tasks_bad_imagery = 0 + project.default_locale = "en" + project.progress_email_sent = False + mock_project.return_value = project + mock_project_info.return_value = { + "name": "TEST_PROJECT" + } # Match what fetch_val expects + mock_project_completion.return_value = 50 # Ensure consistent return value + mock_send_email.return_value = AsyncMock() + mock_settings.return_value = type( + "Settings", (), {"SEND_PROJECT_EMAIL_UPDATES": True} + )() # Mock settings object + + # Mock the database connection context manager + mock_db_connection.return_value.__aenter__.return_value = self.db + # Act + await ProjectService.send_email_on_project_progress(1) + + # Assert + mock_send_email.assert_called_once() + + @patch("backend.services.project_service.db_connection.database.connection") + @patch.object(SMTPService, "send_email_to_contributors_on_project_progress") + @patch.object(Project, "calculate_tasks_percent") + @patch.object(ProjectInfo, "get_dto_for_locale") + @patch.object(ProjectService, "get_project_by_id") + @patch("backend.services.project_service.get_settings") + async def test_send_email_on_project_progress_sends_email_on_project_completion( + self, + mock_settings, + mock_project, + mock_project_info, + mock_project_completion, + mock_send_email, + mock_db_connection, + ): + # Arrange + project = Project() + project.progress_email_sent = False + project.tasks_mapped = 0 + project.tasks_validated = 100 + project.total_tasks = 100 + project.tasks_bad_imagery = 0 + project.default_locale = "en" + project.progress_email_sent = False + mock_project.return_value = project + mock_project_info.return_value = {"name": "TEST_PROJECT"} + mock_project_completion.return_value = 100 + mock_send_email.return_value = AsyncMock() + mock_settings.return_value = type( + "Settings", (), {"SEND_PROJECT_EMAIL_UPDATES": True} + )() + + # Mock the database connection context manager + mock_db_connection.return_value.__aenter__.return_value = self.db + # Act + await ProjectService.send_email_on_project_progress(1) + + # Assert + mock_send_email.assert_called_once() + + @patch("backend.services.project_service.db_connection.database.connection") + @patch.object(SMTPService, "send_email_to_contributors_on_project_progress") + @patch.object(Project, "calculate_tasks_percent") + @patch.object(ProjectInfo, "get_dto_for_locale") + @patch.object(ProjectService, "get_project_by_id") + @patch("backend.services.project_service.get_settings") + async def test_send_email_on_project_progress_doesnt_send_email_except_on_fifty_and_hundred_percent( + self, + mock_settings, + mock_project, + mock_project_info, + mock_project_completion, + mock_send_email, + mock_db_connection, + ): + # Arrange + project = Project() + project.progress_email_sent = False + project.tasks_mapped = 40 + project.tasks_validated = 40 + project.total_tasks = 100 + project.tasks_bad_imagery = 0 + project.default_locale = "en" + project.progress_email_sent = False + mock_project.return_value = project + mock_project_info.return_value = {"name": "TEST_PROJECT"} + mock_project_completion.return_value = 80 + mock_send_email.return_value = AsyncMock() + mock_settings.return_value = type( + "Settings", (), {"SEND_PROJECT_EMAIL_UPDATES": True} + )() + + # Mock the database connection context manager + mock_db_connection.return_value.__aenter__.return_value = self.db + + # Act + await ProjectService.send_email_on_project_progress(1) + + # Assert + assert not mock_send_email.called + + @patch("backend.services.project_service.db_connection.database.connection") + @patch.object(SMTPService, "send_email_to_contributors_on_project_progress") + @patch.object(Project, "calculate_tasks_percent") + @patch.object(ProjectService, "get_project_by_id") + @patch("backend.services.project_service.get_settings") + async def test_send_email_on_project_progress_doesnt_send_email_if_email_already_sent( + self, + mock_settings, + mock_project, + mock_project_completion, + mock_send_email, + mock_db_connection, + ): + # Arrange + project = Project() + project.progress_email_sent = True + project.tasks_mapped = 50 + project.total_tasks = 100 + project.tasks_validated = 0 + project.tasks_bad_imagery = 0 + mock_project.return_value = project + mock_project_completion.return_value = 50 + mock_send_email.return_value = AsyncMock() + mock_settings.return_value = type( + "Settings", (), {"SEND_PROJECT_EMAIL_UPDATES": True} + )() + + # Mock the database connection context manager + mock_db_connection.return_value.__aenter__.return_value = self.db + + # Act + await ProjectService.send_email_on_project_progress(1) + + # Assert + assert not mock_send_email.called + + @patch("backend.services.project_service.db_connection.database.connection") + @patch.object(SMTPService, "send_email_to_contributors_on_project_progress") + @patch.object(ProjectService, "get_project_by_id") + @patch("backend.services.project_service.get_settings") + async def test_send_email_on_project_progress_doesnt_send_email_if_send_project_update_email_is_disabled( + self, mock_settings, mock_project, mock_send_email, mock_db_connection + ): + # Arrange + project = Project() + project.progress_email_sent = False + project.tasks_mapped = 50 + project.total_tasks = 100 + project.tasks_validated = 0 + project.tasks_bad_imagery = 0 + project.default_locale = "en" + mock_project.return_value = project + mock_send_email.return_value = AsyncMock() + mock_settings.return_value = type( + "Settings", (), {"SEND_PROJECT_EMAIL_UPDATES": False} + )() + + # Mock the database connection context manager + mock_db_connection.return_value.__aenter__.return_value = self.db + # Act + await ProjectService.send_email_on_project_progress(1) + + # Assert + assert not mock_send_email.called diff --git a/tests/api/unit/services/test_recommendation_service.py b/tests/api/unit/services/test_recommendation_service.py new file mode 100644 index 0000000000..3a44ab4b1f --- /dev/null +++ b/tests/api/unit/services/test_recommendation_service.py @@ -0,0 +1,140 @@ +import pytest +import pandas as pd + +from backend.models.postgis.project import ProjectStatus +from backend.services.recommendation_service import ProjectRecommendationService +from tests.api.helpers.test_helpers import create_canned_project + + +@pytest.mark.anyio +class TestProjectRecommendationService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.db = db_connection_fixture + request.cls.service = ProjectRecommendationService() + + async def create_project(self, is_published=True): + test_project, test_user, project_id = await create_canned_project(self.db) + if is_published: + await self.db.execute( + "UPDATE projects SET status = :status WHERE id = :id", + {"status": ProjectStatus.PUBLISHED.value, "id": project_id}, + ) + return test_project, project_id + + async def get_all_published_projects(self): + query = """ + SELECT p.id, p.default_locale, p.difficulty, p.mapping_types, p.country, + COALESCE(ARRAY_AGG(pi.interest_id), ARRAY[]::INTEGER[]) AS categories + FROM projects p + LEFT JOIN ( + SELECT pi.project_id, i.id as interest_id + FROM project_interests pi + JOIN interests i ON pi.interest_id = i.id + ) pi ON p.id = pi.project_id + WHERE p.status = :status + GROUP BY p.id + """ + result = await self.db.fetch_all( + query=query, values={"status": ProjectStatus.PUBLISHED.value} + ) + return result + + async def test_get_all_published_projects_returns_published_projects(self): + project1, project1_id = await self.create_project() + project2, project2_id = await self.create_project() + project3, project3_id = await self.create_project(is_published=False) + + projects = await self.get_all_published_projects() + + assert len(projects) == 2 + assert projects[0].id == project1_id + assert projects[1].id == project2_id + + async def test_get_all_published_projects_returns_empty_list_when_no_published_projects( + self, + ): + await self.create_project(is_published=False) + projects = await self.get_all_published_projects() + assert projects == [] + + async def test_get_all_published_projects_only_returns_required_fields_for_recommendation( + self, + ): + project, project_id = await self.create_project() + projects = await self.get_all_published_projects() + + assert len(projects) == 1 + + result = projects[0] + assert result.id == project_id + assert result.mapping_types == project.mapping_types + assert result.default_locale == project.default_locale + assert result.difficulty == project.difficulty + assert result.country == project.country + assert len(result) == 6 + + async def test_mlb_transform_adds_new_transformed_columns(self): + test_df = pd.DataFrame( + { + "id": [1, 2], + "mapping_types": [["building", "waterway"], ["building", "road"]], + } + ) + + transformed_df = self.service.mlb_transform( + test_df, "mapping_types", "mapping_types_" + ) + + assert transformed_df.shape == (2, 4) + assert transformed_df["mapping_types_building"].tolist() == [1, 1] + assert transformed_df["mapping_types_waterway"].tolist() == [1, 0] + assert transformed_df["mapping_types_road"].tolist() == [0, 1] + + async def test_mlb_transform_adds_new_transformed_columns_when_column_is_empty( + self, + ): + test_df = pd.DataFrame( + { + "id": [1, 2], + "mapping_types": [[], []], + } + ) + + transformed_df = self.service.mlb_transform( + test_df, "mapping_types", "mapping_types_" + ) + + assert transformed_df.shape == (2, 1) + assert transformed_df["id"].tolist() == [1, 2] + + async def test_one_hot_encoding_adds_new_transformed_columns(self): + test_df = pd.DataFrame( + { + "id": [1, 2], + "mapping_types": ["building", "waterway"], + } + ) + + transformed_df = self.service.one_hot_encoding(test_df, ["mapping_types"]) + + assert transformed_df.shape == (2, 3) + assert transformed_df["mapping_types_building"].tolist() == [1, 0] + assert transformed_df["mapping_types_waterway"].tolist() == [0, 1] + + async def test_one_hot_encoding_adds_new_transformed_columns_when_column_is_empty( + self, + ): + test_df = pd.DataFrame( + { + "id": [1, 2], + "mapping_types": [None, None], + } + ) + + transformed_df = self.service.one_hot_encoding(test_df, ["mapping_types"]) + + assert transformed_df.shape == (2, 1) + assert transformed_df["id"].tolist() == [1, 2] diff --git a/tests/api/unit/services/test_stats_service.py b/tests/api/unit/services/test_stats_service.py new file mode 100644 index 0000000000..a4f0800e47 --- /dev/null +++ b/tests/api/unit/services/test_stats_service.py @@ -0,0 +1,250 @@ +import pytest +from backend.models.postgis.project import Project +from backend.models.postgis.user import User +from backend.services.stats_service import StatsService, TaskStatus + + +@pytest.mark.anyio +class TestStatsService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + async def test_update_after_mapping_increments_counter(self): + # Arrange + test_project = Project() + test_project.tasks_mapped = 0 + + test_user = User() + test_user.tasks_mapped = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, test_user, TaskStatus.READY, TaskStatus.MAPPED, self.db + ) + + # Assert + assert test_project.tasks_mapped == 1 + assert test_user.tasks_mapped == 1 + + async def test_same_state_keeps_counter(self): + # Arrange + test_project = Project() + test_project.tasks_mapped = 0 + + test_user = User() + test_user.tasks_mapped = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, test_user, TaskStatus.MAPPED, TaskStatus.MAPPED, self.db + ) + + # Assert + assert test_project.tasks_mapped == 0 + assert test_user.tasks_mapped == 0 + + async def test_update_after_validating_increments_counter(self): + # Arrange + test_project = Project() + test_project.tasks_mapped = 1 + test_project.tasks_validated = 0 + + test_user = User() + test_user.tasks_validated = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, test_user, TaskStatus.MAPPED, TaskStatus.VALIDATED, self.db + ) + + # Assert + assert test_project.tasks_mapped == 0 + assert test_project.tasks_validated == 1 + assert test_user.tasks_validated == 1 + + async def test_update_after_flagging_bad_imagery(self): + # Arrange + test_project = Project() + test_project.tasks_bad_imagery = 0 + + test_user = User() + test_user.tasks_invalidated = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, test_user, TaskStatus.READY, TaskStatus.BADIMAGERY, self.db + ) + + # Assert + assert test_project.tasks_bad_imagery == 1 + + async def test_update_after_invalidating_mapped_task_sets_counters_correctly(self): + # Arrange + test_project = Project() + test_project.tasks_mapped = 1 + + test_user = User() + test_user.tasks_invalidated = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, test_user, TaskStatus.MAPPED, TaskStatus.INVALIDATED, self.db + ) + + # Assert + assert test_project.tasks_mapped == 0 + assert test_user.tasks_invalidated == 1 + + async def test_update_after_invalidating_bad_imagery_task_sets_counters_correctly( + self, + ): + # Arrange + test_project = Project() + test_project.tasks_bad_imagery = 1 + + test_user = User() + test_user.tasks_invalidated = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, + test_user, + TaskStatus.BADIMAGERY, + TaskStatus.INVALIDATED, + self.db, + ) + + # Assert + assert test_project.tasks_bad_imagery == 0 + assert test_user.tasks_invalidated == 1 + + async def test_update_after_invalidating_validated_task_sets_counters_correctly( + self, + ): + # Arrange + test_project = Project() + test_project.tasks_validated = 1 + + test_user = User() + test_user.tasks_invalidated = 0 + + # Act + test_project, test_user = await StatsService._update_tasks_stats( + test_project, + test_user, + TaskStatus.VALIDATED, + TaskStatus.INVALIDATED, + self.db, + ) + + # Assert + assert test_project.tasks_validated == 0 + assert test_user.tasks_invalidated == 1 + + async def test_tasks_state_representation(self): + # Arrange + test_project = Project() + test_project.tasks_mapped = 0 + test_project.tasks_validated = 0 + test_project.tasks_bad_imagery = 0 + + test_mapper = User() + test_mapper.tasks_mapped = 0 + test_mapper.tasks_validated = 0 + test_mapper.tasks_invalidated = 0 + + test_validator = User() + test_validator.tasks_mapped = 0 + test_validator.tasks_validated = 0 + test_validator.tasks_invalidated = 0 + + test_admin = User() + test_admin.tasks_mapped = 0 + test_admin.tasks_validated = 0 + test_admin.tasks_invalidated = 0 + + # Mapper marks task as mapped + test_project, test_mapper = await StatsService._update_tasks_stats( + test_project, test_mapper, TaskStatus.READY, TaskStatus.MAPPED, self.db + ) + + # Validator marks task as bad imagery + test_project, test_validator = await StatsService._update_tasks_stats( + test_project, + test_validator, + TaskStatus.MAPPED, + TaskStatus.BADIMAGERY, + self.db, + ) + + # Admin undoes marking task as bad imagery + test_project, test_admin = await StatsService._update_tasks_stats( + test_project, + test_admin, + TaskStatus.BADIMAGERY, + TaskStatus.MAPPED, + self.db, + "undo", + ) + + # Validator marks task as invalid + test_project, test_validator = await StatsService._update_tasks_stats( + test_project, + test_validator, + TaskStatus.MAPPED, + TaskStatus.INVALIDATED, + self.db, + ) + + # Mapper marks task as mapped + test_project, test_mapper = await StatsService._update_tasks_stats( + test_project, + test_mapper, + TaskStatus.INVALIDATED, + TaskStatus.MAPPED, + self.db, + ) + + # Admin undoes mapping (but test_mapper is passed as the function parameter) + test_project, test_mapper = await StatsService._update_tasks_stats( + test_project, + test_mapper, + TaskStatus.MAPPED, + TaskStatus.INVALIDATED, + self.db, + "undo", + ) + + # Mapper marks task as mapped again + test_project, test_mapper = await StatsService._update_tasks_stats( + test_project, + test_mapper, + TaskStatus.INVALIDATED, + TaskStatus.MAPPED, + self.db, + ) + + # Validator marks task as valid + test_project, test_validator = await StatsService._update_tasks_stats( + test_project, + test_validator, + TaskStatus.MAPPED, + TaskStatus.VALIDATED, + self.db, + ) + + # Assert + assert test_project.tasks_mapped == 0 + assert test_project.tasks_validated == 1 + assert test_project.tasks_bad_imagery == 0 + assert test_mapper.tasks_mapped == 2 + assert test_mapper.tasks_validated == 0 + assert test_mapper.tasks_invalidated == 0 + assert test_validator.tasks_mapped == 0 + assert test_validator.tasks_validated == 1 + assert test_validator.tasks_invalidated == 1 + assert test_admin.tasks_mapped == 0 + assert test_admin.tasks_validated == 0 + assert test_admin.tasks_invalidated == 0 diff --git a/tests/api/unit/services/test_team_service.py b/tests/api/unit/services/test_team_service.py new file mode 100644 index 0000000000..cc485aa281 --- /dev/null +++ b/tests/api/unit/services/test_team_service.py @@ -0,0 +1,71 @@ +import pytest +from backend.exceptions import NotFound +from backend.models.dtos.team_dto import TeamSearchDTO +from backend.services.team_service import TeamService +from tests.api.helpers.test_helpers import create_canned_team, create_canned_user + + +@pytest.mark.anyio +class TestTeamService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + """Fixture to set up test data before running tests.""" + assert db_connection_fixture is not None, "Database connection is not available" + + request.cls.test_user = await create_canned_user(db_connection_fixture) + request.cls.test_team = await create_canned_team(db_connection_fixture) + request.cls.db = db_connection_fixture + + assert self.test_user is not None, "Failed to create test user" + assert self.test_team is not None, "Failed to create test team" + + async def test_search_team(self): + """Test searching for a team.""" + team_search_dto = TeamSearchDTO( + user_id=self.test_user.id, + team_name=self.test_team.name, + # member=self.test_user.id, + organisation=self.test_team.organisation_id, + ) + result = await TeamService.get_all_teams(team_search_dto, self.db) + + assert len(result.teams) == 1 + assert result.teams[0].team_id == self.test_team.id + assert result.teams[0].name == self.test_team.name + assert result.teams[0].organisation_id == self.test_team.organisation_id + + async def test_get_team_as_dto(self): + """Test fetching a team as DTO.""" + result = await TeamService.get_team_as_dto( + self.test_team.id, self.test_user.id, False, self.db + ) + assert result.team_id == self.test_team.id + + async def test_add_team_project(self, db_connection_fixture): + """Test adding a user to a team.""" + await TeamService.add_team_member( + self.test_team.id, self.test_user.id, 1, active=True, db=self.db + ) + is_active = await TeamService.is_user_an_active_team_member( + self.test_team.id, self.test_user.id, self.db + ) + assert is_active + + async def test_delete_team_project(self, db_connection_fixture): + """Test deleting a team.""" + await TeamService.delete_team(self.test_team.id, self.db) + with pytest.raises(NotFound): + await TeamService.get_team_by_id(self.test_team.id, self.db) + + async def test_leave_team(self, db_connection_fixture): + """Test leaving a team.""" + await TeamService.add_team_member( + self.test_team.id, self.test_user.id, 1, active=True, db=self.db + ) + await TeamService.leave_team( + self.test_team.id, self.test_user.username, self.db + ) + is_active = await TeamService.is_user_an_active_team_member( + self.test_team.id, self.test_user.id, self.db + ) + assert not is_active diff --git a/tests/api/unit/services/test_validator_service.py b/tests/api/unit/services/test_validator_service.py new file mode 100644 index 0000000000..97e6cfa266 --- /dev/null +++ b/tests/api/unit/services/test_validator_service.py @@ -0,0 +1,219 @@ +import pytest +from unittest.mock import MagicMock, patch +from backend.models.dtos.validator_dto import ValidatedTask +from backend.services.users.user_service import UserService +from backend.services.validator_service import ( + LockForValidationDTO, + NotFound, + ProjectService, + Task, + TaskStatus, + UnlockAfterValidationDTO, + UserLicenseError, + ValidatingNotAllowed, + ValidatorService, + ValidatorServiceError, +) + + +@pytest.mark.anyio +class TestValidatorService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + + self.unlock_task_stub = Task() + self.unlock_task_stub.task_status = TaskStatus.MAPPED.value + self.unlock_task_stub.lock_holder_id = 123456 + + @patch.object(Task, "get") + async def test_lock_tasks_for_validation_raises_error_if_task_not_found( + self, mock_task + ): + # Arrange + mock_task.return_value = None + + lock_dto = LockForValidationDTO(project_id=1, task_ids=[1, 2], user_id=123456) + + # Act / Assert + with pytest.raises(NotFound): + await ValidatorService.lock_tasks_for_validation(lock_dto, self.db) + + @patch.object(UserService, "is_user_blocked") + @patch.object(Task, "get") + async def test_lock_tasks_for_validation_raises_error_if_task_not_mapped( + self, mock_task, mock_blocked + ): + # Arrange + task_stub = Task() + task_stub.task_status = TaskStatus.READY.value + mock_task.return_value = task_stub + mock_blocked.return_value = False + + lock_dto = LockForValidationDTO(project_id=1, task_ids=[1, 2], user_id=123456) + + # Act / Assert + with pytest.raises(ValidatorServiceError): + await ValidatorService.lock_tasks_for_validation(lock_dto, self.db) + + @patch.object(UserService, "is_user_an_admin") + @patch.object(Task, "get") + @patch.object(ProjectService, "is_user_permitted_to_validate") + async def test_lock_tasks_raises_error_if_project_validator_only_and_user_not_validator( + self, mock_project, mock_task, mock_user + ): + # Arrange + task_stub = Task() + task_stub.task_status = TaskStatus.MAPPED.value + mock_task.return_value = task_stub + mock_project.return_value = (False, ValidatingNotAllowed.USER_NOT_VALIDATOR) + mock_user.return_value = True + + lock_dto = LockForValidationDTO(project_id=1, task_ids=[1, 2], user_id=1234) + + # Act / Assert + with pytest.raises(ValidatorServiceError): + await ValidatorService.lock_tasks_for_validation(lock_dto, self.db) + + @patch.object(UserService, "is_user_an_admin") + @patch.object(Task, "get") + @patch.object(ProjectService, "is_user_permitted_to_validate") + async def test_lock_tasks_raises_error_if_user_has_not_accepted_license( + self, mock_project, mock_task, mock_user + ): + # Arrange + task_stub = Task() + task_stub.task_status = TaskStatus.MAPPED.value + mock_task.return_value = task_stub + mock_project.return_value = ( + False, + ValidatingNotAllowed.USER_NOT_ACCEPTED_LICENSE, + ) + mock_user.return_value = True + + lock_dto = LockForValidationDTO(project_id=1, task_ids=[1, 2], user_id=1234) + + # Act / Assert + with pytest.raises(UserLicenseError): + await ValidatorService.lock_tasks_for_validation(lock_dto, self.db) + + @patch.object(Task, "get") + async def test_unlock_tasks_for_validation_raises_error_if_task_not_found( + self, mock_task + ): + # Arrange + mock_task.return_value = None + + validated_task = ValidatedTask(task_id=1) + validated_tasks = [validated_task] + + unlock_dto = UnlockAfterValidationDTO( + project_id=1, validated_tasks=validated_tasks, user_id=123456 + ) + + # Act / Assert + with pytest.raises(NotFound): + await ValidatorService.unlock_tasks_after_validation( + unlock_dto, self.db, MagicMock() + ) + + @patch.object(Task, "get") + async def test_unlock_tasks_for_validation_raises_error_if_task_not_done_or_validated( + self, mock_task + ): + # Arrange + self.unlock_task_stub.task_status = TaskStatus.READY.value + mock_task.return_value = self.unlock_task_stub + + validated_task = ValidatedTask() + validated_task.task_id = 1 + validated_tasks = [validated_task] + + unlock_dto = UnlockAfterValidationDTO( + project_id=1, validated_tasks=validated_tasks, user_id=123456 + ) + + # Act / Assert + with pytest.raises(ValidatorServiceError): + await ValidatorService.unlock_tasks_after_validation( + unlock_dto, self.db, MagicMock() + ) + + @patch.object(Task, "get") + async def test_unlock_tasks_for_validation_raises_error_if_task_not_locked( + self, mock_task + ): + # Arrange + self.unlock_task_stub.task_locked = False # Assuming this attribute exists + mock_task.return_value = self.unlock_task_stub + + validated_task = ValidatedTask() + validated_task.task_id = 1 + validated_tasks = [validated_task] + + unlock_dto = UnlockAfterValidationDTO( + project_id=1, validated_tasks=validated_tasks, user_id=123456 + ) + + # Act / Assert + with pytest.raises(ValidatorServiceError): + await ValidatorService.unlock_tasks_after_validation( + unlock_dto, self.db, MagicMock() + ) + + @patch.object(Task, "get") + async def test_unlock_tasks_for_validation_raises_error_if_user_doesnt_own_the_lock( + self, mock_task + ): + # Arrange + mock_task.return_value = self.unlock_task_stub + + validated_task = ValidatedTask() + validated_task.task_id = 1 + validated_tasks = [validated_task] + + # Different from lock_holder_id (123456) + unlock_dto = UnlockAfterValidationDTO( + project_id=1, validated_tasks=validated_tasks, user_id=12 + ) + + # Act / Assert + with pytest.raises(ValidatorServiceError): + await ValidatorService.unlock_tasks_after_validation( + unlock_dto, self.db, MagicMock() + ) + + @patch.object(UserService, "is_user_an_admin") + async def test_user_can_validate_task_returns_false_when_user_not_a_pm_and_validating_own_task( + self, mock_user + ): + # Arrange + mock_user.return_value = False + user_id = 1234 + mapped_by = 1234 + + # Act + user_can_validate_task = await ValidatorService._user_can_validate_task( + user_id, mapped_by, self.db + ) + + # Assert + assert not user_can_validate_task + + @patch.object(UserService, "is_user_an_admin") + async def test_user_can_validate_task_returns_true_when_user_not_a_pm_and_not_validating_own_task( + self, mock_user + ): + # Arrange + mock_user.return_value = False + user_id = 5678 + mapped_by = 1234 + + # Act + user_can_validate_task = await ValidatorService._user_can_validate_task( + user_id, mapped_by, self.db + ) + + # Assert + assert user_can_validate_task diff --git a/tests/api/unit/services/users/test_authentication_service.py b/tests/api/unit/services/users/test_authentication_service.py new file mode 100644 index 0000000000..ba3a2c167e --- /dev/null +++ b/tests/api/unit/services/users/test_authentication_service.py @@ -0,0 +1,65 @@ +from urllib.parse import parse_qs, urlparse + +from backend.services.messaging.smtp_service import SMTPService +from backend.services.users.authentication_service import AuthenticationService + + +class TestAuthenticationService: + def test_generate_session_token_for_user_returns_session_token(self): + # Act + session_token = AuthenticationService.generate_session_token_for_user(12345678) + + # Assert + assert session_token is not None + + def test_is_valid_token_validates_user_token(self): + # Arrange + session_token = AuthenticationService.generate_session_token_for_user(12345678) + invalid_session_token = session_token + "x" + + # Act + is_valid_token, user_id = AuthenticationService.is_valid_token( + session_token, 604800 + ) + is_invalid_token, _user_id = AuthenticationService.is_valid_token( + invalid_session_token, 604800 + ) + + # Assert + assert is_valid_token is True + assert user_id == 12345678 + assert is_invalid_token is False + assert _user_id == "BadSignature- Bad Token Signature" + + def test_get_authentication_failed_url_returns_expected_url(self): + # Act + auth_failed_url = AuthenticationService.get_authentication_failed_url() + + # Assert + parsed_url = urlparse(auth_failed_url) + assert parsed_url.path == "/auth-failed" + + def test_get_email_validated_url_returns_expected_url(self): + # Act + email_validated_url = AuthenticationService._get_email_validated_url(True) + + # Assert + parsed_url = urlparse(email_validated_url) + assert parsed_url.path == "/validate-email" + + def test_can_parse_email_verification_token(self): + # Arrange + test_email = "test@test.com" + auth_url = SMTPService._generate_email_verification_url(test_email, "mrtest") + + parsed_url = urlparse(auth_url) + query = parse_qs(parsed_url.query) + + # Act + is_valid, email_address = AuthenticationService.is_valid_token( + query["token"][0], 86400 + ) + + # Assert + assert is_valid is True + assert email_address == test_email diff --git a/tests/api/unit/services/users/test_osm_service.py b/tests/api/unit/services/users/test_osm_service.py new file mode 100644 index 0000000000..4753817fe8 --- /dev/null +++ b/tests/api/unit/services/users/test_osm_service.py @@ -0,0 +1,24 @@ +import pytest +from backend.services.users.osm_service import OSMService, OSMServiceError +from tests.backend.helpers.test_helpers import get_canned_osm_user_json_details + + +class TestOsmService: + def test_parse_osm_user_details_raises_error_if_user_not_found(self): + # Arrange + osm_response = get_canned_osm_user_json_details() + + # Act & Assert + with pytest.raises(OSMServiceError): + OSMService._parse_osm_user_details_response(osm_response, "wont-find") + + def test_parse_osm_user_details_can_parse_valid_osm_response(self): + # Arrange + osm_response = get_canned_osm_user_json_details() + + # Act + dto = OSMService._parse_osm_user_details_response(osm_response, "user") + + # Assert + assert dto.account_created == "2017-01-23T16:23:22Z" + assert dto.changeset_count == 16 diff --git a/tests/api/unit/services/users/test_user_service.py b/tests/api/unit/services/users/test_user_service.py new file mode 100644 index 0000000000..553ef5d537 --- /dev/null +++ b/tests/api/unit/services/users/test_user_service.py @@ -0,0 +1,67 @@ +import pytest +from backend.services.users.user_service import ( + NotFound, + UserRole, + UserService, + UserServiceError, +) +from tests.api.helpers.test_helpers import create_canned_user + + +@pytest.mark.anyio +class TestUserService: + @pytest.fixture(autouse=True) + async def setup_test_data(self, db_connection_fixture, request): + assert db_connection_fixture is not None, "Database connection is not available" + request.cls.db = db_connection_fixture + request.cls.test_user = await create_canned_user(db_connection_fixture) + + async def test_get_user_by_id_returns_user(self): + # Act + user = await UserService.get_user_by_id(self.test_user.id, self.db) + + # Assert + assert user.username == self.test_user.username + + async def test_get_user_by_id_raises_error_if_user_not_found(self): + with pytest.raises(NotFound): + await UserService.get_user_by_id(123456, self.db) + + async def test_get_user_by_username_returns_user(self): + # Act + user = await UserService.get_user_by_username(self.test_user.username, self.db) + + # Assert + assert user.id == self.test_user.id + + async def test_get_user_by_username_raises_error_if_user_not_found(self): + with pytest.raises(NotFound): + await UserService.get_user_by_username("Thinkwhere", self.db) + + async def test_is_user_admin_returns_true_for_admin(self): + query = """ + UPDATE users + SET role = :role + WHERE id = :user_id + """ + await self.db.execute( + query, values={"user_id": self.test_user.id, "role": UserRole.ADMIN.value} + ) + + # Act + result = await UserService.is_user_an_admin(self.test_user.id, self.db) + + # Assert + assert result is True + + async def test_is_user_admin_returns_false_for_non_admin(self): + # Assert + assert await UserService.is_user_an_admin(self.test_user.id, self.db) is False + + async def test_unknown_role_raise_error_when_setting_role(self): + with pytest.raises(UserServiceError): + await UserService.add_role_to_user(1, "test", "TEST", self.db) + + async def test_unknown_level_raise_error_when_setting_level(self): + with pytest.raises(UserServiceError): + await UserService.set_user_mapping_level("test", "TEST", self.db) diff --git a/tests/backend/base.py b/tests/backend/base.py index 06e56c7e65..a322aabe80 100644 --- a/tests/backend/base.py +++ b/tests/backend/base.py @@ -1,12 +1,13 @@ -import unittest import os - +import unittest from typing import Optional -from shapely.geometry import shape -from backend import create_app, db + import geojson from flask import Flask from flask_sqlalchemy import SQLAlchemy +from shapely.geometry import shape + +from backend import create_app, db def clean_db(db): diff --git a/tests/backend/helpers/test_helpers.py b/tests/backend/helpers/test_helpers.py index 6273e44ca7..1cb40c488c 100644 --- a/tests/backend/helpers/test_helpers.py +++ b/tests/backend/helpers/test_helpers.py @@ -1,36 +1,35 @@ -import geojson import base64 import json import os -from typing import Tuple import xml.etree.ElementTree as ET -from backend.models.dtos.organisation_dto import ( - UpdateOrganisationDTO, -) +from typing import Tuple + +import geojson + +from backend.models.dtos.organisation_dto import UpdateOrganisationDTO from backend.models.dtos.project_dto import ( DraftProjectDTO, ProjectDTO, ProjectInfoDTO, - ProjectStatus, ProjectPriority, + ProjectStatus, ) -from backend.models.postgis.project import Project, ProjectTeams from backend.models.postgis.campaign import Campaign -from backend.models.postgis.statuses import MappingLevel, TaskStatus from backend.models.postgis.message import Message, MessageType from backend.models.postgis.notification import Notification +from backend.models.postgis.organisation import Organisation +from backend.models.postgis.project import Project, ProjectTeams +from backend.models.postgis.statuses import MappingLevel, TaskStatus from backend.models.postgis.task import Task from backend.models.postgis.team import Team, TeamMembers from backend.models.postgis.user import User -from backend.models.postgis.organisation import Organisation -from backend.services.users.authentication_service import AuthenticationService from backend.services.interests_service import Interest -from backend.services.license_service import LicenseService, LicenseDTO +from backend.services.license_service import LicenseDTO, LicenseService from backend.services.mapping_issues_service import ( - MappingIssueCategoryService, MappingIssueCategoryDTO, + MappingIssueCategoryService, ) - +from backend.services.users.authentication_service import AuthenticationService TEST_USER_ID = 777777 TEST_USERNAME = "Thinkwhere Test" diff --git a/tests/backend/integration/api/campaigns/test_resources.py b/tests/backend/integration/api/campaigns/test_resources.py index 0e2cfbf154..c38886ec52 100644 --- a/tests/backend/integration/api/campaigns/test_resources.py +++ b/tests/backend/integration/api/campaigns/test_resources.py @@ -1,13 +1,13 @@ +from backend.exceptions import get_message_from_sub_code +from backend.models.postgis.statuses import UserRole from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_organisation, - generate_encoded_token, create_canned_user, - return_canned_user, + generate_encoded_token, return_canned_campaign, + return_canned_user, ) -from backend.models.postgis.statuses import UserRole -from backend.exceptions import get_message_from_sub_code CAMPAIGN_NAME = "Test Campaign" CAMPAIGN_ID = 1 diff --git a/tests/backend/integration/api/comments/test_resources.py b/tests/backend/integration/api/comments/test_resources.py index 4bb5603b00..94b5d6499b 100644 --- a/tests/backend/integration/api/comments/test_resources.py +++ b/tests/backend/integration/api/comments/test_resources.py @@ -1,16 +1,15 @@ from unittest.mock import patch +from backend.exceptions import NotFound, get_message_from_sub_code +from backend.models.postgis.statuses import UserRole +from backend.services.messaging.chat_service import ChatMessageDTO, ChatService +from backend.services.messaging.message_service import MessageService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_project, generate_encoded_token, return_canned_user, ) -from backend.exceptions import NotFound, get_message_from_sub_code -from backend.models.postgis.statuses import UserRole -from backend.services.messaging.chat_service import ChatService, ChatMessageDTO -from backend.services.messaging.message_service import MessageService - TEST_MESSAGE = "Test comment" PROJECT_NOT_FOUND_SUB_CODE = "PROJECT_NOT_FOUND" diff --git a/tests/backend/integration/api/interests/test_resources.py b/tests/backend/integration/api/interests/test_resources.py index 2fd7c4d885..e345906057 100644 --- a/tests/backend/integration/api/interests/test_resources.py +++ b/tests/backend/integration/api/interests/test_resources.py @@ -1,12 +1,12 @@ +from backend.exceptions import get_message_from_sub_code from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( add_manager_to_organisation, - create_canned_organisation, create_canned_interest, - generate_encoded_token, + create_canned_organisation, create_canned_user, + generate_encoded_token, ) -from backend.exceptions import get_message_from_sub_code TEST_INTEREST_NAME = "test_interest" NEW_INTEREST_NAME = "New Interest" diff --git a/tests/backend/integration/api/issues/test_resources.py b/tests/backend/integration/api/issues/test_resources.py index 99e6e57d60..46b6bf8e55 100644 --- a/tests/backend/integration/api/issues/test_resources.py +++ b/tests/backend/integration/api/issues/test_resources.py @@ -1,12 +1,11 @@ +from backend.exceptions import get_message_from_sub_code from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_mapping_issue, create_canned_user, generate_encoded_token, - create_canned_mapping_issue, ) -from backend.exceptions import get_message_from_sub_code - TEST_ISSUE_NAME = "Test Issue" TEST_ISSUE_DESCRIPTION = "Test issue description" ISSUE_NOT_FOUND_SUB_CODE = "ISSUE_CATEGORY_NOT_FOUND" diff --git a/tests/backend/integration/api/licenses/test_actions.py b/tests/backend/integration/api/licenses/test_actions.py index 472ade7c40..ce20ef4cb4 100644 --- a/tests/backend/integration/api/licenses/test_actions.py +++ b/tests/backend/integration/api/licenses/test_actions.py @@ -1,13 +1,11 @@ +from backend.exceptions import get_message_from_sub_code from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_license, create_canned_user, generate_encoded_token, - create_canned_license, ) -from backend.exceptions import get_message_from_sub_code - - LICENSE_NOT_FOUND_SUB_CODE = "LICENSE_NOT_FOUND" LICENSE_NOT_FOUND_MESSAGE = get_message_from_sub_code(LICENSE_NOT_FOUND_SUB_CODE) diff --git a/tests/backend/integration/api/licenses/test_resources.py b/tests/backend/integration/api/licenses/test_resources.py index f46639837f..c34be1b019 100644 --- a/tests/backend/integration/api/licenses/test_resources.py +++ b/tests/backend/integration/api/licenses/test_resources.py @@ -1,12 +1,11 @@ +from backend.exceptions import get_message_from_sub_code from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_license, create_canned_user, generate_encoded_token, - create_canned_license, ) -from backend.exceptions import get_message_from_sub_code - TEST_LICENSE_NAME = "test_license" TEST_LICENSE_DESCRIPTION = "test license" TEST_LICENSE_PLAINTEXT = "test license" diff --git a/tests/backend/integration/api/notifications/test_actions.py b/tests/backend/integration/api/notifications/test_actions.py index ad2fde488b..f71839a4c5 100644 --- a/tests/backend/integration/api/notifications/test_actions.py +++ b/tests/backend/integration/api/notifications/test_actions.py @@ -1,8 +1,8 @@ from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_message, create_canned_user, generate_encoded_token, - create_canned_message, ) TEST_SUBJECT = "Test subject" diff --git a/tests/backend/integration/api/notifications/test_resources.py b/tests/backend/integration/api/notifications/test_resources.py index 07f0d55417..794d2b0348 100644 --- a/tests/backend/integration/api/notifications/test_resources.py +++ b/tests/backend/integration/api/notifications/test_resources.py @@ -1,18 +1,17 @@ from datetime import datetime, timedelta -from tests.backend.base import BaseTestCase +from backend.exceptions import get_message_from_sub_code from backend.models.postgis.message import MessageType +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - create_canned_user, - generate_encoded_token, - return_canned_user, create_canned_message, create_canned_notification, create_canned_project, + create_canned_user, + generate_encoded_token, + return_canned_user, ) -from backend.exceptions import get_message_from_sub_code - TEST_SUBJECT = "Test subject" TEST_MESSAGE = "This is a test message" NOT_FOUND_SUB_CODE = "MESSAGE_NOT_FOUND" diff --git a/tests/backend/integration/api/organisations/test_campaigns.py b/tests/backend/integration/api/organisations/test_campaigns.py index b0e1aeb861..883baac9f2 100644 --- a/tests/backend/integration/api/organisations/test_campaigns.py +++ b/tests/backend/integration/api/organisations/test_campaigns.py @@ -3,8 +3,8 @@ create_canned_organisation, create_canned_project, generate_encoded_token, - return_canned_user, return_canned_campaign, + return_canned_user, ) from tests.backend.integration.api.campaigns.test_resources import ( CAMPAIGN_NOT_FOUND_MESSAGE, diff --git a/tests/backend/integration/api/organisations/test_resources.py b/tests/backend/integration/api/organisations/test_resources.py index 8aec9f9f65..2fba3124e8 100644 --- a/tests/backend/integration/api/organisations/test_resources.py +++ b/tests/backend/integration/api/organisations/test_resources.py @@ -1,19 +1,17 @@ import base64 +from backend.exceptions import get_message_from_sub_code +from backend.services.users.authentication_service import AuthenticationService from tests.backend.base import BaseTestCase, db from tests.backend.helpers.test_helpers import ( + add_manager_to_organisation, create_canned_organisation, create_canned_project, create_canned_user, - add_manager_to_organisation, generate_encoded_token, return_canned_user, ) -from backend.exceptions import get_message_from_sub_code -from backend.services.users.authentication_service import AuthenticationService - - TEST_USER_ID = 777777 TEST_USERNAME = "Thinkwhere Test" ORG_NOT_FOUND_SUB_CODE = "ORGANISATION_NOT_FOUND" diff --git a/tests/backend/integration/api/projects/test_actions.py b/tests/backend/integration/api/projects/test_actions.py index a1d7b7a6d8..973c2d6b49 100644 --- a/tests/backend/integration/api/projects/test_actions.py +++ b/tests/backend/integration/api/projects/test_actions.py @@ -1,26 +1,27 @@ import json -import geojson from unittest.mock import patch +import geojson + +from backend.models.postgis.statuses import ( + TaskStatus, + TeamMemberFunctions, + TeamRoles, + UserRole, +) +from backend.models.postgis.task import Task from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + add_manager_to_organisation, + add_user_to_team, + assign_team_to_project, + create_canned_organisation, + create_canned_project, + create_canned_team, create_canned_user, generate_encoded_token, get_canned_json, - create_canned_project, return_canned_user, - create_canned_organisation, - add_manager_to_organisation, - create_canned_team, - assign_team_to_project, - add_user_to_team, -) -from backend.models.postgis.task import Task -from backend.models.postgis.statuses import ( - UserRole, - TaskStatus, - TeamMemberFunctions, - TeamRoles, ) diff --git a/tests/backend/integration/api/projects/test_activities.py b/tests/backend/integration/api/projects/test_activities.py index 0c78b6176f..9ae1cf4754 100644 --- a/tests/backend/integration/api/projects/test_activities.py +++ b/tests/backend/integration/api/projects/test_activities.py @@ -1,6 +1,6 @@ +from backend.models.postgis.task import Task, TaskAction, TaskStatus from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project -from backend.models.postgis.task import Task, TaskStatus, TaskAction class TestProjectsLastActivitiesAPI(BaseTestCase): diff --git a/tests/backend/integration/api/projects/test_campaign.py b/tests/backend/integration/api/projects/test_campaign.py index e6c1aa05b7..e633a95a5d 100644 --- a/tests/backend/integration/api/projects/test_campaign.py +++ b/tests/backend/integration/api/projects/test_campaign.py @@ -1,12 +1,12 @@ +from backend.models.dtos.campaign_dto import CampaignProjectDTO +from backend.services.campaign_service import CampaignService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_campaign, create_canned_project, - return_canned_user, generate_encoded_token, + return_canned_campaign, + return_canned_user, ) -from backend.models.dtos.campaign_dto import CampaignProjectDTO -from backend.services.campaign_service import CampaignService class TestGetProjectsCampaignsAPI(BaseTestCase): diff --git a/tests/backend/integration/api/projects/test_contributions.py b/tests/backend/integration/api/projects/test_contributions.py index df153f0780..e893082098 100644 --- a/tests/backend/integration/api/projects/test_contributions.py +++ b/tests/backend/integration/api/projects/test_contributions.py @@ -1,8 +1,8 @@ from datetime import datetime +from backend.models.postgis.task import Task, TaskStatus from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project, return_canned_user -from backend.models.postgis.task import Task, TaskStatus class TestProjectsContributionsAPI(BaseTestCase): diff --git a/tests/backend/integration/api/projects/test_favourites.py b/tests/backend/integration/api/projects/test_favourites.py index 558c37717c..2ece33287f 100644 --- a/tests/backend/integration/api/projects/test_favourites.py +++ b/tests/backend/integration/api/projects/test_favourites.py @@ -1,9 +1,9 @@ +from backend.services.project_service import ProjectService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_project, generate_encoded_token, ) -from backend.services.project_service import ProjectService class TestValidateProjectFavouritedAPI(BaseTestCase): diff --git a/tests/backend/integration/api/projects/test_resources.py b/tests/backend/integration/api/projects/test_resources.py index 4b011e616c..2c111628a3 100644 --- a/tests/backend/integration/api/projects/test_resources.py +++ b/tests/backend/integration/api/projects/test_resources.py @@ -1,56 +1,57 @@ -import geojson from datetime import datetime, timedelta +import geojson + +from backend.exceptions import NotFound +from backend.models.dtos.campaign_dto import CampaignProjectDTO +from backend.models.dtos.project_dto import ( + DraftProjectDTO, + is_known_editor, + is_known_mapping_permission, + is_known_mapping_type, + is_known_project_difficulty, + is_known_project_priority, + is_known_project_status, + is_known_task_creation_mode, + is_known_validation_permission, +) +from backend.models.postgis.project import Project, ProjectDTO +from backend.models.postgis.statuses import ( + MappingPermission, + MappingTypes, + ProjectDifficulty, + ProjectPriority, + ProjectStatus, + TaskStatus, + TeamMemberFunctions, + TeamRoles, + UserRole, + ValidationPermission, +) +from backend.models.postgis.task import Task +from backend.services.campaign_service import CampaignService +from backend.services.interests_service import InterestService +from backend.services.mapping_service import MappingService +from backend.services.project_service import ProjectAdminService, ProjectService +from backend.services.validator_service import ValidatorService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( add_manager_to_organisation, - create_canned_team, - return_canned_team, add_user_to_team, assign_team_to_project, + create_canned_interest, create_canned_organisation, - return_canned_organisation, create_canned_project, + create_canned_team, generate_encoded_token, - return_canned_user, - return_canned_draft_project_json, get_canned_json, return_canned_campaign, - create_canned_interest, + return_canned_draft_project_json, + return_canned_organisation, + return_canned_team, + return_canned_user, update_project_with_info, ) -from backend.exceptions import NotFound -from backend.models.postgis.project import Project, ProjectDTO -from backend.models.postgis.task import Task -from backend.services.campaign_service import CampaignService -from backend.models.dtos.campaign_dto import CampaignProjectDTO -from backend.models.postgis.statuses import ( - UserRole, - ProjectStatus, - TeamMemberFunctions, - TeamRoles, - ValidationPermission, - MappingPermission, - ProjectDifficulty, - ProjectPriority, - MappingTypes, - TaskStatus, -) -from backend.services.project_service import ProjectService, ProjectAdminService -from backend.services.validator_service import ValidatorService -from backend.services.mapping_service import MappingService -from backend.services.interests_service import InterestService -from backend.models.dtos.project_dto import ( - DraftProjectDTO, - is_known_project_status, - is_known_project_priority, - is_known_project_difficulty, - is_known_editor, - is_known_mapping_type, - is_known_task_creation_mode, - is_known_validation_permission, - is_known_mapping_permission, -) TEST_USER_USERNAME = "Test User" TEST_USER_ID = 11111 diff --git a/tests/backend/integration/api/projects/test_statistics.py b/tests/backend/integration/api/projects/test_statistics.py index 5dc06a8e1f..150fb00da4 100644 --- a/tests/backend/integration/api/projects/test_statistics.py +++ b/tests/backend/integration/api/projects/test_statistics.py @@ -1,12 +1,12 @@ import time +from backend.models.postgis.task import Task, TaskStatus +from backend.services.project_admin_service import ProjectAdminService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_project, generate_encoded_token, ) -from backend.services.project_admin_service import ProjectAdminService -from backend.models.postgis.task import Task, TaskStatus class TestProjectStatisticsAPI(BaseTestCase): diff --git a/tests/backend/integration/api/system/test_authentication.py b/tests/backend/integration/api/system/test_authentication.py index 62ff06316c..4be7194f5a 100644 --- a/tests/backend/integration/api/system/test_authentication.py +++ b/tests/backend/integration/api/system/test_authentication.py @@ -1,14 +1,15 @@ from unittest.mock import patch from urllib import parse + from oauthlib.oauth2.rfc6749.errors import InvalidGrantError from backend import osm -from tests.backend.base import BaseTestCase from backend.config import EnvironmentConfig -from backend.services.users.user_service import UserService, NotFound from backend.services.messaging.message_service import MessageService from backend.services.messaging.smtp_service import SMTPService from backend.services.users.authentication_service import AuthenticationService +from backend.services.users.user_service import NotFound, UserService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import return_canned_user from tests.backend.integration.api.users.test_resources import ( USER_NOT_FOUND_MESSAGE, diff --git a/tests/backend/integration/api/system/test_banner.py b/tests/backend/integration/api/system/test_banner.py index 426397de5c..769df98589 100644 --- a/tests/backend/integration/api/system/test_banner.py +++ b/tests/backend/integration/api/system/test_banner.py @@ -1,7 +1,7 @@ import base64 -from tests.backend.base import BaseTestCase from backend.services.users.authentication_service import AuthenticationService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import return_canned_user diff --git a/tests/backend/integration/api/system/test_image_upload.py b/tests/backend/integration/api/system/test_image_upload.py index 23cfb617bc..5581bf10d4 100644 --- a/tests/backend/integration/api/system/test_image_upload.py +++ b/tests/backend/integration/api/system/test_image_upload.py @@ -1,13 +1,11 @@ from unittest.mock import patch - from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_user, generate_encoded_token, ) - IMAGE_UPLOAD_API_URL = "http://localhost:5000" IMAGE_UPLOAD_API_KEY = "test" diff --git a/tests/backend/integration/api/system/test_statistics.py b/tests/backend/integration/api/system/test_statistics.py index 005bb00100..687eccf34b 100644 --- a/tests/backend/integration/api/system/test_statistics.py +++ b/tests/backend/integration/api/system/test_statistics.py @@ -1,7 +1,6 @@ from backend.models.postgis.task import Task - from tests.backend.base import BaseTestCase -from tests.backend.helpers.test_helpers import return_canned_user, create_canned_project +from tests.backend.helpers.test_helpers import create_canned_project, return_canned_user class TestSystemStatisticsAPI(BaseTestCase): diff --git a/tests/backend/integration/api/tasks/test_actions.py b/tests/backend/integration/api/tasks/test_actions.py index 959cffb900..d27621a08a 100644 --- a/tests/backend/integration/api/tasks/test_actions.py +++ b/tests/backend/integration/api/tasks/test_actions.py @@ -1,29 +1,28 @@ from unittest.mock import patch + from backend.models.postgis.statuses import ( - TaskStatus, + MappingLevel, MappingNotAllowed, ProjectStatus, + TaskStatus, ValidatingNotAllowed, ValidationPermission, - MappingLevel, ) - +from backend.models.postgis.task import Task, TaskAction from backend.services.project_admin_service import ProjectAdminService from backend.services.project_service import ProjectService from backend.services.users.user_service import UserService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_license, create_canned_project, - return_canned_user, generate_encoded_token, - create_canned_license, + return_canned_user, ) from tests.backend.integration.api.users.test_resources import ( - USER_NOT_FOUND_SUB_CODE, USER_NOT_FOUND_MESSAGE, + USER_NOT_FOUND_SUB_CODE, ) -from backend.models.postgis.task import Task, TaskAction - PROJECT_NOT_FOUND_SUB_CODE = "PROJECT_NOT_FOUND" TASK_NOT_FOUND_SUB_CODE = "TASK_NOT_FOUND" diff --git a/tests/backend/integration/api/tasks/test_resources.py b/tests/backend/integration/api/tasks/test_resources.py index 218953fe56..731e70af8c 100644 --- a/tests/backend/integration/api/tasks/test_resources.py +++ b/tests/backend/integration/api/tasks/test_resources.py @@ -1,19 +1,18 @@ import xml.etree.ElementTree as ET +from backend.models.postgis.statuses import TaskStatus, UserRole +from backend.models.postgis.task import Task +from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_service import ProjectService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - create_canned_project, - return_canned_user, + add_manager_to_organisation, create_canned_organisation, + create_canned_project, generate_encoded_token, - add_manager_to_organisation, + return_canned_user, ) -from backend.models.postgis.statuses import UserRole, TaskStatus -from backend.services.project_service import ProjectService -from backend.services.project_admin_service import ProjectAdminService -from backend.models.postgis.task import Task - class TestGetTasksQueriesJsonAPI(BaseTestCase): def setUp(self): diff --git a/tests/backend/integration/api/tasks/test_statistics.py b/tests/backend/integration/api/tasks/test_statistics.py index 4e53d1c4fe..9d6578d523 100644 --- a/tests/backend/integration/api/tasks/test_statistics.py +++ b/tests/backend/integration/api/tasks/test_statistics.py @@ -1,16 +1,14 @@ from datetime import datetime, timedelta from backend.models.postgis.task import Task, TaskStatus -from backend.services.campaign_service import CampaignService, CampaignProjectDTO - - +from backend.services.campaign_service import CampaignProjectDTO, CampaignService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_user, - generate_encoded_token, create_canned_campaign, - create_canned_project, create_canned_organisation, + create_canned_project, + generate_encoded_token, + return_canned_user, ) diff --git a/tests/backend/integration/api/teams/test_actions.py b/tests/backend/integration/api/teams/test_actions.py index 931ba0412c..fd72734995 100644 --- a/tests/backend/integration/api/teams/test_actions.py +++ b/tests/backend/integration/api/teams/test_actions.py @@ -1,19 +1,20 @@ from unittest.mock import patch + +from backend.exceptions import get_message_from_sub_code +from backend.models.postgis.statuses import ( + TeamJoinMethod, + TeamMemberFunctions, + UserRole, +) from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_organisation, - generate_encoded_token, + add_user_to_team, + create_canned_team, create_canned_user, + generate_encoded_token, + return_canned_organisation, return_canned_user, - create_canned_team, - add_user_to_team, -) -from backend.models.postgis.statuses import ( - UserRole, - TeamJoinMethod, - TeamMemberFunctions, ) -from backend.exceptions import get_message_from_sub_code TEST_ADMIN_USERNAME = "Test Admin" TEST_MESSAGE = "This is a test message" diff --git a/tests/backend/integration/api/teams/test_resources.py b/tests/backend/integration/api/teams/test_resources.py index 5ff3baeaa5..fa5855687e 100644 --- a/tests/backend/integration/api/teams/test_resources.py +++ b/tests/backend/integration/api/teams/test_resources.py @@ -1,23 +1,21 @@ +from backend.models.postgis.statuses import UserRole from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_organisation, - generate_encoded_token, + create_canned_team, create_canned_user, - return_canned_user, + generate_encoded_token, return_canned_team, - create_canned_team, -) -from tests.backend.integration.api.teams.test_actions import ( - TEAM_NOT_FOUND_SUB_CODE, - TEAM_NOT_FOUND_MESSAGE, + return_canned_user, ) from tests.backend.integration.api.organisations.test_resources import ( ORG_NOT_FOUND_MESSAGE, ORG_NOT_FOUND_SUB_CODE, ) - - -from backend.models.postgis.statuses import UserRole +from tests.backend.integration.api.teams.test_actions import ( + TEAM_NOT_FOUND_MESSAGE, + TEAM_NOT_FOUND_SUB_CODE, +) TEST_ORGANISATION_NAME = "Kathmandu Living Labs" TEST_ORGANISATION_SLUG = "KLL" diff --git a/tests/backend/integration/api/users/test_actions.py b/tests/backend/integration/api/users/test_actions.py index 0dcc76cfba..412b9985d7 100644 --- a/tests/backend/integration/api/users/test_actions.py +++ b/tests/backend/integration/api/users/test_actions.py @@ -1,13 +1,13 @@ from unittest.mock import patch +from backend.models.postgis.statuses import UserRole +from backend.services.messaging.smtp_service import SMTPService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_user, - generate_encoded_token, create_canned_interest, + generate_encoded_token, + return_canned_user, ) -from backend.services.messaging.smtp_service import SMTPService -from backend.models.postgis.statuses import UserRole TEST_EMAIL = "test@test.com" diff --git a/tests/backend/integration/api/users/test_openstreetmap.py b/tests/backend/integration/api/users/test_openstreetmap.py index ee1d9be270..bcd4ad3f2e 100644 --- a/tests/backend/integration/api/users/test_openstreetmap.py +++ b/tests/backend/integration/api/users/test_openstreetmap.py @@ -1,11 +1,11 @@ from unittest.mock import patch +from backend.services.users.osm_service import OSMService, OSMServiceError, UserOSMDTO from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_user, generate_encoded_token, + return_canned_user, ) -from backend.services.users.osm_service import OSMServiceError, OSMService, UserOSMDTO TEST_USERNAME = "testuser" TEST_USER_ID = 111111 diff --git a/tests/backend/integration/api/users/test_resources.py b/tests/backend/integration/api/users/test_resources.py index fadd26e264..eee186d01e 100644 --- a/tests/backend/integration/api/users/test_resources.py +++ b/tests/backend/integration/api/users/test_resources.py @@ -1,17 +1,14 @@ -from backend.models.postgis.task import Task, TaskStatus -from backend.models.postgis.statuses import UserGender, UserRole, MappingLevel from backend.exceptions import get_message_from_sub_code - - +from backend.models.postgis.statuses import MappingLevel, UserGender, UserRole +from backend.models.postgis.task import Task, TaskStatus from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - return_canned_user, - generate_encoded_token, - create_canned_project, create_canned_interest, + create_canned_project, + generate_encoded_token, + return_canned_user, ) - TEST_USERNAME = "test_user" TEST_USER_ID = 1111111 TEST_EMAIL = "test@hotmail.com" diff --git a/tests/backend/integration/api/users/test_statistics.py b/tests/backend/integration/api/users/test_statistics.py index d7a095ac5e..7dcab35638 100644 --- a/tests/backend/integration/api/users/test_statistics.py +++ b/tests/backend/integration/api/users/test_statistics.py @@ -1,5 +1,5 @@ -import time import random +import time from datetime import datetime, timedelta from backend.models.postgis.task import Task, TaskStatus diff --git a/tests/backend/integration/api/users/test_tasks.py b/tests/backend/integration/api/users/test_tasks.py index 36788d3a73..74a154fc87 100644 --- a/tests/backend/integration/api/users/test_tasks.py +++ b/tests/backend/integration/api/users/test_tasks.py @@ -1,10 +1,10 @@ +from backend.models.postgis.statuses import ProjectStatus +from backend.models.postgis.task import Task, TaskStatus from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_project, generate_encoded_token, ) -from backend.models.postgis.task import Task, TaskStatus -from backend.models.postgis.statuses import ProjectStatus class TetUsersTasksAPI(BaseTestCase): diff --git a/tests/backend/integration/models/postgis/test_project.py b/tests/backend/integration/models/postgis/test_project.py index fb0ba7b664..ef7aeb583d 100644 --- a/tests/backend/integration/models/postgis/test_project.py +++ b/tests/backend/integration/models/postgis/test_project.py @@ -1,15 +1,15 @@ import geojson -from backend.models.dtos.project_dto import DraftProjectDTO -from tests.backend.base import BaseTestCase +from backend.models.dtos.project_dto import DraftProjectDTO from backend.models.postgis.project import ( - Task, + Project, ProjectDTO, - ProjectStatus, ProjectPriority, - Project, + ProjectStatus, + Task, ) from backend.models.postgis.project_info import ProjectInfoDTO +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( create_canned_project, return_canned_draft_project_json, diff --git a/tests/backend/integration/models/postgis/test_user.py b/tests/backend/integration/models/postgis/test_user.py index 6464c089b1..39aa1bd63a 100644 --- a/tests/backend/integration/models/postgis/test_user.py +++ b/tests/backend/integration/models/postgis/test_user.py @@ -1,4 +1,4 @@ -from backend.models.postgis.user import User, UserRole, MappingLevel +from backend.models.postgis.user import MappingLevel, User, UserRole from tests.backend.base import BaseTestCase diff --git a/tests/backend/integration/services/grid/test_split_service.py b/tests/backend/integration/services/grid/test_split_service.py index 8bd61ac617..24c20f6bf8 100644 --- a/tests/backend/integration/services/grid/test_split_service.py +++ b/tests/backend/integration/services/grid/test_split_service.py @@ -1,17 +1,16 @@ import json -from shapely.geometry import Polygon -from geoalchemy2 import shape from unittest.mock import patch import geojson +from geoalchemy2 import shape +from shapely.geometry import Polygon from backend.models.dtos.grid_dto import SplitTaskDTO from backend.models.postgis.project import Project from backend.models.postgis.task import Task from backend.services.grid.split_service import SplitService, SplitServiceError from tests.backend.base import BaseTestCase -from tests.backend.helpers.test_helpers import get_canned_json -from tests.backend.helpers.test_helpers import create_canned_project +from tests.backend.helpers.test_helpers import create_canned_project, get_canned_json class TestSplitService(BaseTestCase): diff --git a/tests/backend/integration/services/messaging/test_chat_service.py b/tests/backend/integration/services/messaging/test_chat_service.py index 1a52724718..6e343c1f4d 100644 --- a/tests/backend/integration/services/messaging/test_chat_service.py +++ b/tests/backend/integration/services/messaging/test_chat_service.py @@ -2,17 +2,17 @@ from unittest.mock import patch from backend.exceptions import NotFound -from backend.models.postgis.statuses import ProjectStatus -from backend.models.postgis.team import TeamRoles, TeamMemberFunctions -from tests.backend.base import BaseTestCase from backend.models.dtos.message_dto import ChatMessageDTO +from backend.models.postgis.statuses import ProjectStatus +from backend.models.postgis.team import TeamMemberFunctions, TeamRoles from backend.services.messaging.chat_service import ChatService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + add_user_to_team, + assign_team_to_project, create_canned_project, - return_canned_user, create_canned_team, - assign_team_to_project, - add_user_to_team, + return_canned_user, ) diff --git a/tests/backend/integration/services/messaging/test_messaging_service.py b/tests/backend/integration/services/messaging/test_messaging_service.py index fa96870729..911d51f6e0 100644 --- a/tests/backend/integration/services/messaging/test_messaging_service.py +++ b/tests/backend/integration/services/messaging/test_messaging_service.py @@ -1,19 +1,19 @@ from unittest.mock import patch from backend.models.dtos.message_dto import MessageDTO -from backend.services.messaging.message_service import MessageService +from backend.models.postgis.message import Message, MessageType, NotFound from backend.models.postgis.statuses import TaskStatus -from backend.models.postgis.message import MessageType, Message, NotFound from backend.models.postgis.task import Task +from backend.services.messaging.message_service import MessageService from backend.services.messaging.smtp_service import SMTPService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( add_manager_to_organisation, create_canned_organisation, - return_canned_user, create_canned_project, + return_canned_user, update_project_with_info, ) -from tests.backend.base import BaseTestCase class TestMessageService(BaseTestCase): diff --git a/tests/backend/integration/services/messaging/test_smtp_service.py b/tests/backend/integration/services/messaging/test_smtp_service.py index e2d2862789..2ca3a52ede 100644 --- a/tests/backend/integration/services/messaging/test_smtp_service.py +++ b/tests/backend/integration/services/messaging/test_smtp_service.py @@ -1,6 +1,7 @@ import os -from urllib.parse import urlparse, parse_qs -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlparse + from flask import current_app from backend.models.postgis.message import Message diff --git a/tests/backend/integration/services/test_favorite_service.py b/tests/backend/integration/services/test_favorite_service.py index ac0813bd79..c5c69e3df9 100644 --- a/tests/backend/integration/services/test_favorite_service.py +++ b/tests/backend/integration/services/test_favorite_service.py @@ -1,8 +1,8 @@ from backend.exceptions import NotFound from backend.services.project_service import ProjectService from backend.services.users.user_service import UserService -from tests.backend.helpers.test_helpers import create_canned_project from tests.backend.base import BaseTestCase +from tests.backend.helpers.test_helpers import create_canned_project class TestFavoriteService(BaseTestCase): diff --git a/tests/backend/integration/services/test_featured_projects_services.py b/tests/backend/integration/services/test_featured_projects_services.py index b1333a0f06..b3268a2bf4 100644 --- a/tests/backend/integration/services/test_featured_projects_services.py +++ b/tests/backend/integration/services/test_featured_projects_services.py @@ -1,8 +1,7 @@ from backend.exceptions import NotFound from backend.services.project_service import ProjectService - -from tests.backend.helpers.test_helpers import create_canned_project from tests.backend.base import BaseTestCase +from tests.backend.helpers.test_helpers import create_canned_project class TestFeaturedProjectService(BaseTestCase): diff --git a/tests/backend/integration/services/test_license_service.py b/tests/backend/integration/services/test_license_service.py index b5930df167..335719eb05 100644 --- a/tests/backend/integration/services/test_license_service.py +++ b/tests/backend/integration/services/test_license_service.py @@ -1,5 +1,5 @@ from backend.exceptions import NotFound -from backend.services.license_service import LicenseService, LicenseDTO +from backend.services.license_service import LicenseDTO, LicenseService from tests.backend.base import BaseTestCase diff --git a/tests/backend/integration/services/test_mapping_service.py b/tests/backend/integration/services/test_mapping_service.py index 4d4b052a5b..e8158d433d 100644 --- a/tests/backend/integration/services/test_mapping_service.py +++ b/tests/backend/integration/services/test_mapping_service.py @@ -1,12 +1,12 @@ import datetime import xml.etree.ElementTree as ET from unittest.mock import patch -from backend.services.mapping_service import MappingService, Task + from backend.models.postgis.task import TaskStatus +from backend.services.mapping_service import MappingService, Task from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project - ORG_NAME = "HOT Tasking Manager" diff --git a/tests/backend/integration/services/test_organisation_service.py b/tests/backend/integration/services/test_organisation_service.py index 676fe9dad2..924cdeb7dc 100644 --- a/tests/backend/integration/services/test_organisation_service.py +++ b/tests/backend/integration/services/test_organisation_service.py @@ -1,18 +1,19 @@ from datetime import datetime + from schematics.exceptions import UndefinedValueError -from tests.backend.base import BaseTestCase from backend.models.postgis.statuses import ProjectStatus, TeamVisibility +from backend.services.organisation_service import NotFound, OrganisationService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - add_manager_to_organisation, - create_canned_organisation, TEST_ORGANISATION_ID, TEST_USER_ID, + add_manager_to_organisation, + create_canned_organisation, create_canned_project, create_canned_user, return_canned_team, ) -from backend.services.organisation_service import OrganisationService, NotFound class TestOrgansitaionService(BaseTestCase): diff --git a/tests/backend/integration/services/test_project_admin_service.py b/tests/backend/integration/services/test_project_admin_service.py index e5515a6704..aee7db3714 100644 --- a/tests/backend/integration/services/test_project_admin_service.py +++ b/tests/backend/integration/services/test_project_admin_service.py @@ -1,33 +1,34 @@ -from unittest.mock import patch, MagicMock import json +from unittest.mock import MagicMock, patch + from flask import current_app -from backend.models.postgis.project import Project, User, NotFound, ProjectPriority -from backend.models.postgis.statuses import UserRole, ProjectDifficulty -from backend.services.messaging.message_service import MessageService -from backend.services.team_service import TeamService -from tests.backend.base import BaseTestCase from backend.models.dtos.organisation_dto import ListOrganisationsDTO -from backend.services.project_admin_service import ( - ProjectAdminService, - ProjectAdminServiceError, - InvalidGeoJson, -) from backend.models.dtos.project_dto import ( DraftProjectDTO, ProjectDTO, ProjectInfoDTO, ProjectStatus, ) +from backend.models.postgis.project import NotFound, Project, ProjectPriority, User +from backend.models.postgis.statuses import ProjectDifficulty, UserRole +from backend.services.messaging.message_service import MessageService from backend.services.organisation_service import OrganisationService +from backend.services.project_admin_service import ( + InvalidGeoJson, + ProjectAdminService, + ProjectAdminServiceError, +) +from backend.services.team_service import TeamService from backend.services.users.user_service import UserService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( add_manager_to_organisation, + create_canned_organisation, create_canned_project, + create_canned_user, return_canned_draft_project_json, return_canned_user, - create_canned_organisation, - create_canned_user, ) diff --git a/tests/backend/integration/services/test_project_search_service.py b/tests/backend/integration/services/test_project_search_service.py index 46498b417e..979462e44d 100644 --- a/tests/backend/integration/services/test_project_search_service.py +++ b/tests/backend/integration/services/test_project_search_service.py @@ -1,11 +1,13 @@ import json -from backend.services.project_search_service import ProjectSearchService -from backend.services.users.user_service import UserService -from backend.models.postgis.project import ProjectInfo, Project -from shapely.geometry import Polygon from unittest.mock import patch + +from shapely.geometry import Polygon + from backend.models.dtos.project_dto import ProjectSearchBBoxDTO +from backend.models.postgis.project import Project, ProjectInfo from backend.models.postgis.user import User +from backend.services.project_search_service import ProjectSearchService +from backend.services.users.user_service import UserService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import get_canned_json diff --git a/tests/backend/integration/services/test_recommendation_service.py b/tests/backend/integration/services/test_recommendation_service.py index 50e92728d6..230d3e6ce8 100644 --- a/tests/backend/integration/services/test_recommendation_service.py +++ b/tests/backend/integration/services/test_recommendation_service.py @@ -1,12 +1,13 @@ -import pandas as pd from unittest.mock import patch +import pandas as pd + from backend.models.postgis.project import ProjectStatus -from tests.backend.base import BaseTestCase from backend.services.recommendation_service import ProjectRecommendationService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - create_canned_project, create_canned_interest, + create_canned_project, update_project_with_info, ) diff --git a/tests/backend/integration/services/test_team_service.py b/tests/backend/integration/services/test_team_service.py index 089b236d3b..765cc1fad7 100644 --- a/tests/backend/integration/services/test_team_service.py +++ b/tests/backend/integration/services/test_team_service.py @@ -5,11 +5,7 @@ TeamMemberFunctions, TeamRoles, ) -from backend.services.team_service import ( - TeamService, - MessageService, - TeamServiceError, -) +from backend.services.team_service import MessageService, TeamService, TeamServiceError from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( add_user_to_team, diff --git a/tests/backend/integration/services/test_validation_service.py b/tests/backend/integration/services/test_validation_service.py index dc8edd1452..9bc0a81533 100644 --- a/tests/backend/integration/services/test_validation_service.py +++ b/tests/backend/integration/services/test_validation_service.py @@ -1,10 +1,10 @@ +from backend.models.dtos.validator_dto import RevertUserTasksDTO from backend.services.validator_service import ( + Task, + TaskStatus, ValidatorService, ValidatorServiceError, - TaskStatus, - Task, ) -from backend.models.dtos.validator_dto import RevertUserTasksDTO from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project, return_canned_user diff --git a/tests/backend/integration/services/users/test_authentication_service.py b/tests/backend/integration/services/users/test_authentication_service.py index a38c24af64..9ddff09914 100644 --- a/tests/backend/integration/services/users/test_authentication_service.py +++ b/tests/backend/integration/services/users/test_authentication_service.py @@ -1,23 +1,24 @@ -from unittest.mock import patch import base64 +from unittest.mock import patch from urllib.parse import parse_qs, urlparse -from itsdangerous import URLSafeTimedSerializer + from flask import current_app +from itsdangerous import URLSafeTimedSerializer -from tests.backend.base import BaseTestCase -from tests.backend.helpers.test_helpers import ( - get_canned_osm_user_details, - return_canned_user, - TEST_USERNAME, -) from backend.services.messaging.smtp_service import SMTPService from backend.services.users.authentication_service import ( AuthenticationService, - UserService, + AuthServiceError, MessageService, NotFound, + UserService, verify_token, - AuthServiceError, +) +from tests.backend.base import BaseTestCase +from tests.backend.helpers.test_helpers import ( + TEST_USERNAME, + get_canned_osm_user_details, + return_canned_user, ) TEST_USER_EMAIL = "thinkwheretest@test.com" diff --git a/tests/backend/integration/services/users/test_osm_service.py b/tests/backend/integration/services/users/test_osm_service.py index f10eb328b6..c504215886 100644 --- a/tests/backend/integration/services/users/test_osm_service.py +++ b/tests/backend/integration/services/users/test_osm_service.py @@ -1,5 +1,5 @@ -from tests.backend.base import BaseTestCase from backend.services.users.osm_service import OSMService, OSMServiceError +from tests.backend.base import BaseTestCase class TestOsmService(BaseTestCase): diff --git a/tests/backend/integration/services/users/test_user_service.py b/tests/backend/integration/services/users/test_user_service.py index 3857625a84..c10ba6c8a0 100644 --- a/tests/backend/integration/services/users/test_user_service.py +++ b/tests/backend/integration/services/users/test_user_service.py @@ -1,17 +1,17 @@ from unittest.mock import patch -from tests.backend.base import BaseTestCase from backend.models.postgis.message import Message from backend.services.users.user_service import ( - UserService, MappingLevel, - User, OSMService, + User, UserOSMDTO, + UserService, ) +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( - create_canned_user, create_canned_project, + create_canned_user, get_canned_user, return_canned_user, ) diff --git a/tests/backend/unit/models/dtos/test_mapping_dto.py b/tests/backend/unit/models/dtos/test_mapping_dto.py index e7ab648ebc..0df920e66f 100644 --- a/tests/backend/unit/models/dtos/test_mapping_dto.py +++ b/tests/backend/unit/models/dtos/test_mapping_dto.py @@ -1,4 +1,5 @@ from schematics.exceptions import DataError + from backend.models.dtos.mapping_dto import MappedTaskDTO from backend.models.postgis.statuses import TaskStatus from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/models/dtos/test_project_dto.py b/tests/backend/unit/models/dtos/test_project_dto.py index 711c6c7387..d859359144 100644 --- a/tests/backend/unit/models/dtos/test_project_dto.py +++ b/tests/backend/unit/models/dtos/test_project_dto.py @@ -1,4 +1,5 @@ from schematics.exceptions import DataError + from backend.models.dtos.project_dto import ProjectDTO from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/models/postgis/test_banner.py b/tests/backend/unit/models/postgis/test_banner.py index 31933d4b4c..1278b86af5 100644 --- a/tests/backend/unit/models/postgis/test_banner.py +++ b/tests/backend/unit/models/postgis/test_banner.py @@ -1,8 +1,8 @@ from unittest.mock import patch -from tests.backend.base import BaseTestCase -from backend.models.postgis.banner import Banner from backend.models.dtos.banner_dto import BannerDTO +from backend.models.postgis.banner import Banner +from tests.backend.base import BaseTestCase class TestBanner(BaseTestCase): diff --git a/tests/backend/unit/models/postgis/test_custom_editor.py b/tests/backend/unit/models/postgis/test_custom_editor.py index ddccc9e44e..1ef317a334 100644 --- a/tests/backend/unit/models/postgis/test_custom_editor.py +++ b/tests/backend/unit/models/postgis/test_custom_editor.py @@ -1,6 +1,6 @@ +from backend.models.postgis.custom_editors import CustomEditor, CustomEditorDTO from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project -from backend.models.postgis.custom_editors import CustomEditor, CustomEditorDTO class TestCustomEditor(BaseTestCase): diff --git a/tests/backend/unit/models/postgis/test_message.py b/tests/backend/unit/models/postgis/test_message.py index 3144b6e21f..508bc6c0b9 100644 --- a/tests/backend/unit/models/postgis/test_message.py +++ b/tests/backend/unit/models/postgis/test_message.py @@ -1,6 +1,5 @@ from backend.models.postgis.message import Message, MessageType, NotFound from backend.services.messaging.message_service import MessageService - from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import return_canned_user @@ -96,8 +95,10 @@ def test_mark_all_message_filters_by_type(self): """Test that all message of a certain type can be marked as read""" # Arrange self.send_multiple_welcome_messages(3) + test_user_2 = return_canned_user("test_user_2", 222222222) test_user_2.create() + MessageService.send_team_join_notification( test_user_2.id, test_user_2.username, @@ -109,6 +110,7 @@ def test_mark_all_message_filters_by_type(self): # Act Message.mark_all_messages_read(self.test_user.id, [MessageType.SYSTEM.value]) # Assert + messages = MessageService.get_all_messages( user_id=self.test_user.id, locale="en", diff --git a/tests/backend/unit/models/postgis/test_project.py b/tests/backend/unit/models/postgis/test_project.py index c390815b29..a9aefd31b1 100644 --- a/tests/backend/unit/models/postgis/test_project.py +++ b/tests/backend/unit/models/postgis/test_project.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock + from flask import current_app from backend.exceptions import NotFound @@ -6,10 +7,10 @@ from backend.models.postgis.project import Project from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import ( + create_canned_organisation, create_canned_project, - return_canned_draft_project_json, create_canned_user, - create_canned_organisation, + return_canned_draft_project_json, ) diff --git a/tests/backend/unit/models/postgis/test_task.py b/tests/backend/unit/models/postgis/test_task.py index 677c7fbfef..16d0fed4d0 100644 --- a/tests/backend/unit/models/postgis/test_task.py +++ b/tests/backend/unit/models/postgis/test_task.py @@ -1,14 +1,15 @@ +from unittest.mock import MagicMock, patch + import geojson + +from backend.models.postgis.statuses import TaskStatus from backend.models.postgis.task import ( - InvalidGeoJson, InvalidData, + InvalidGeoJson, Task, TaskAction, TaskHistory, ) -from backend.models.postgis.statuses import TaskStatus -from unittest.mock import patch, MagicMock - from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/models/postgis/test_user.py b/tests/backend/unit/models/postgis/test_user.py index 7a2c5f1a79..cd75923e52 100644 --- a/tests/backend/unit/models/postgis/test_user.py +++ b/tests/backend/unit/models/postgis/test_user.py @@ -1,5 +1,5 @@ +from backend.models.postgis.user import MappingLevel, User, UserRole from tests.backend.base import BaseTestCase -from backend.models.postgis.user import User, UserRole, MappingLevel class TestUser(BaseTestCase): diff --git a/tests/backend/unit/services/grid/test_grid_service.py b/tests/backend/unit/services/grid/test_grid_service.py index cc78e73724..8ae47ba805 100644 --- a/tests/backend/unit/services/grid/test_grid_service.py +++ b/tests/backend/unit/services/grid/test_grid_service.py @@ -1,4 +1,5 @@ import json + import geojson from backend.models.dtos.grid_dto import GridDTO diff --git a/tests/backend/unit/services/messaging/test_template_service.py b/tests/backend/unit/services/messaging/test_template_service.py index 97cf3c808b..7a44731294 100644 --- a/tests/backend/unit/services/messaging/test_template_service.py +++ b/tests/backend/unit/services/messaging/test_template_service.py @@ -1,10 +1,11 @@ +from flask import current_app + from backend.services.messaging.template_service import ( - template_var_replacing, - get_template, clean_html, format_username_link, + get_template, + template_var_replacing, ) -from flask import current_app from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/test_mapping_service.py b/tests/backend/unit/services/test_mapping_service.py index 21700db801..731de0c30c 100644 --- a/tests/backend/unit/services/test_mapping_service.py +++ b/tests/backend/unit/services/test_mapping_service.py @@ -1,18 +1,19 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + +from backend.models.dtos.mapping_dto import LockTaskDTO, MappedTaskDTO +from backend.models.postgis.project_info import ProjectInfo +from backend.models.postgis.task import TaskAction, TaskHistory, User from backend.services.mapping_service import ( + MappingNotAllowed, MappingService, - Task, MappingServiceError, - TaskStatus, - ProjectService, NotFound, + ProjectService, StatsService, - MappingNotAllowed, + Task, + TaskStatus, UserLicenseError, ) -from backend.models.postgis.project_info import ProjectInfo -from backend.models.dtos.mapping_dto import MappedTaskDTO, LockTaskDTO -from backend.models.postgis.task import TaskHistory, TaskAction, User from backend.services.messaging.message_service import MessageService from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/test_organisation_service.py b/tests/backend/unit/services/test_organisation_service.py index ccb4f8385a..420f41e197 100644 --- a/tests/backend/unit/services/test_organisation_service.py +++ b/tests/backend/unit/services/test_organisation_service.py @@ -1,7 +1,8 @@ from unittest.mock import patch + from backend.models.postgis.statuses import UserRole +from backend.services.organisation_service import NotFound, OrganisationService from tests.backend.base import BaseTestCase -from backend.services.organisation_service import OrganisationService, NotFound from tests.backend.helpers.test_helpers import ( create_canned_organisation, create_canned_user, diff --git a/tests/backend/unit/services/test_project_admin_service.py b/tests/backend/unit/services/test_project_admin_service.py index 6ab65612ae..f3693b4460 100644 --- a/tests/backend/unit/services/test_project_admin_service.py +++ b/tests/backend/unit/services/test_project_admin_service.py @@ -1,15 +1,16 @@ import json from unittest.mock import MagicMock, patch + +from backend.models.dtos.project_dto import ProjectInfoDTO +from backend.models.postgis.task import Task from backend.services.project_admin_service import ( - ProjectAdminService, InvalidGeoJson, + LicenseService, + NotFound, Project, + ProjectAdminService, ProjectAdminServiceError, - NotFound, - LicenseService, ) -from backend.models.dtos.project_dto import ProjectInfoDTO -from backend.models.postgis.task import Task from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/test_project_search_service.py b/tests/backend/unit/services/test_project_search_service.py index 7074ec5aa8..72bbd23a55 100644 --- a/tests/backend/unit/services/test_project_search_service.py +++ b/tests/backend/unit/services/test_project_search_service.py @@ -1,8 +1,5 @@ from backend.models.dtos.project_dto import ProjectSearchDTO -from backend.models.postgis.statuses import ( - ProjectStatus, - ProjectDifficulty, -) +from backend.models.postgis.statuses import ProjectDifficulty, ProjectStatus from backend.services.project_search_service import ProjectSearchService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project diff --git a/tests/backend/unit/services/test_project_service.py b/tests/backend/unit/services/test_project_service.py index f986f61258..b704537987 100644 --- a/tests/backend/unit/services/test_project_service.py +++ b/tests/backend/unit/services/test_project_service.py @@ -1,30 +1,31 @@ from unittest.mock import patch + from flask import current_app +from backend.models.dtos.project_dto import LockedTasksForUser +from backend.models.postgis.task import Task from backend.services.messaging.smtp_service import SMTPService from backend.services.project_service import ( - ProjectService, - Project, + MappingLevel, + MappingNotAllowed, NotFound, + Project, + ProjectAdminService, + ProjectInfo, + ProjectService, ProjectStatus, - MappingLevel, UserService, - MappingNotAllowed, ValidatingNotAllowed, - ProjectInfo, ) -from backend.services.project_service import ProjectAdminService -from backend.models.dtos.project_dto import LockedTasksForUser -from backend.models.postgis.task import Task from tests.backend.base import BaseTestCase class TestProjectService(BaseTestCase): def setUp(self): super().setUp() - current_app.config[ - "SEND_PROJECT_EMAIL_UPDATES" - ] = True # Set to true to test email sending + current_app.config["SEND_PROJECT_EMAIL_UPDATES"] = ( + True # Set to true to test email sending + ) @patch.object(Project, "get") def test_project_service_raises_error_if_project_not_found(self, mock_project): @@ -272,7 +273,7 @@ def test_send_email_on_project_progress_doesnt_send_email_if_send_project_update # Act ProjectService.send_email_on_project_progress(1) # Assert - current_app.config[ - "SEND_PROJECT_EMAIL_UPDATES" - ] = True # Set to true for other tests + current_app.config["SEND_PROJECT_EMAIL_UPDATES"] = ( + True # Set to true for other tests + ) self.assertFalse(mock_send_email.called) diff --git a/tests/backend/unit/services/test_recommendation_service.py b/tests/backend/unit/services/test_recommendation_service.py index cbf5d1788e..e8ecf8bb20 100644 --- a/tests/backend/unit/services/test_recommendation_service.py +++ b/tests/backend/unit/services/test_recommendation_service.py @@ -1,7 +1,8 @@ import pandas as pd -from tests.backend.base import BaseTestCase + from backend.models.postgis.project import ProjectStatus from backend.services.recommendation_service import ProjectRecommendationService +from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_project diff --git a/tests/backend/unit/services/test_stats_service.py b/tests/backend/unit/services/test_stats_service.py index c6a5625764..48947ce0ce 100644 --- a/tests/backend/unit/services/test_stats_service.py +++ b/tests/backend/unit/services/test_stats_service.py @@ -1,6 +1,6 @@ -from backend.services.stats_service import StatsService, TaskStatus from backend.models.postgis.project import Project from backend.models.postgis.user import User +from backend.services.stats_service import StatsService, TaskStatus from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/test_team_service.py b/tests/backend/unit/services/test_team_service.py index a45f4ad875..c42f1c0cb1 100644 --- a/tests/backend/unit/services/test_team_service.py +++ b/tests/backend/unit/services/test_team_service.py @@ -1,6 +1,6 @@ from backend.exceptions import NotFound -from backend.services.team_service import TeamService from backend.models.dtos.team_dto import TeamSearchDTO +from backend.services.team_service import TeamService from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_team, create_canned_user diff --git a/tests/backend/unit/services/test_validator_service.py b/tests/backend/unit/services/test_validator_service.py index 51c3548254..08bd2da8bc 100644 --- a/tests/backend/unit/services/test_validator_service.py +++ b/tests/backend/unit/services/test_validator_service.py @@ -3,16 +3,16 @@ from backend.models.dtos.validator_dto import ValidatedTask from backend.services.users.user_service import UserService from backend.services.validator_service import ( - ValidatorService, - Task, - NotFound, LockForValidationDTO, + NotFound, + ProjectService, + Task, TaskStatus, - ValidatorServiceError, UnlockAfterValidationDTO, - ProjectService, - ValidatingNotAllowed, UserLicenseError, + ValidatingNotAllowed, + ValidatorService, + ValidatorServiceError, ) from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/users/test_authentication_service.py b/tests/backend/unit/services/users/test_authentication_service.py index 1084de285d..86c44e560e 100644 --- a/tests/backend/unit/services/users/test_authentication_service.py +++ b/tests/backend/unit/services/users/test_authentication_service.py @@ -1,7 +1,7 @@ -from urllib.parse import urlparse, parse_qs +from urllib.parse import parse_qs, urlparse -from backend.services.users.authentication_service import AuthenticationService from backend.services.messaging.smtp_service import SMTPService +from backend.services.users.authentication_service import AuthenticationService from tests.backend.base import BaseTestCase diff --git a/tests/backend/unit/services/users/test_osm_service.py b/tests/backend/unit/services/users/test_osm_service.py index aadd29366a..b431554914 100644 --- a/tests/backend/unit/services/users/test_osm_service.py +++ b/tests/backend/unit/services/users/test_osm_service.py @@ -1,6 +1,6 @@ +from backend.services.users.osm_service import OSMService, OSMServiceError from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import get_canned_osm_user_json_details -from backend.services.users.osm_service import OSMService, OSMServiceError class TestOsmService(BaseTestCase): diff --git a/tests/backend/unit/services/users/test_user_service.py b/tests/backend/unit/services/users/test_user_service.py index 79dd4099dd..b6221c6784 100644 --- a/tests/backend/unit/services/users/test_user_service.py +++ b/tests/backend/unit/services/users/test_user_service.py @@ -1,10 +1,11 @@ from unittest.mock import patch + from backend.models.postgis.user import User from backend.services.users.user_service import ( + NotFound, + UserRole, UserService, UserServiceError, - UserRole, - NotFound, ) from tests.backend.base import BaseTestCase from tests.backend.helpers.test_helpers import create_canned_user, return_canned_user diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..0dabaff524 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1663 @@ +version = 1 +requires-python = ">=3.9, <=3.11" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "aiocache" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/64/b945b8025a9d1e6e2138845f4022165d3b337f55f50984fbc6a4c0a1e355/aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713", size = 132196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d7/15d67e05b235d1ed8c3ce61688fe4d84130e72af1657acadfaac3479f4cf/aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d", size = 28199 }, +] + +[[package]] +name = "aiosmtplib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/66/64d78bcb36c0a3da4610d5564c0422cb726051fb5dbd136a8839d8b91574/aiosmtplib-2.0.2.tar.gz", hash = "sha256:138599a3227605d29a9081b646415e9e793796ca05322a78f69179f0135016a3", size = 56295 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/be/6902c91523a5faedee22e260e17fc23521fd63b2fec46dd5e5fcda62da8f/aiosmtplib-2.0.2-py3-none-any.whl", hash = "sha256:1e631a7a3936d3e11c6a144fb8ffd94bb4a99b714f2cb433e825d88b698e37bc", size = 27149 }, +] + +[[package]] +name = "alembic" +version = "1.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/e3/3d9b95470606b93bd6e6d5c899ed9d0049dfa10246ecca25b18c2c708cdf/alembic-1.11.1.tar.gz", hash = "sha256:6a810a6b012c88b33458fceb869aef09ac75d6ace5291915ba7fae44de372c01", size = 1176522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/00/46a4f66ad54c661350a1cd5daae4b4ab2232486c55635ee12ff12958b03f/alembic-1.11.1-py3-none-any.whl", hash = "sha256:dc871798a601fab38332e38d6ddb38d5e734f60034baeb8e2db5b642fccd8ab8", size = 224526 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "apscheduler" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "setuptools" }, + { name = "six" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/ed/f1ad88e88208c24db80dcaae7a5a339bb283956984f8fa59933d2806413a/APScheduler-3.10.1.tar.gz", hash = "sha256:0293937d8f6051a0f493359440c1a1b93e882c57daf0197afeff0e727777b96e", size = 100376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/08/952d9570f4897dc2b30166fca5afd3a2cd19b3d408abdb470978484e8a09/APScheduler-3.10.1-py3-none-any.whl", hash = "sha256:e813ad5ada7aff36fb08cdda746b520531eaac7757832abc204868ba78e0c8f6", size = 59238 }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, +] + +[[package]] +name = "asyncpg" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/11/7a6000244eaeb6b8ed2238bf33477c486515d6133f2c295913aca3ba4a00/asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e", size = 820455 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/df/5cc866069c3a248a67d59a3de495afec34b4d36ed74101da4dfa1f456167/asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169", size = 669258 }, + { url = "https://files.pythonhosted.org/packages/a9/eb/569047f87d6b7ced42352af3771c1b1e6d39584f072e11068e3e3b4bde68/asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385", size = 650833 }, + { url = "https://files.pythonhosted.org/packages/b8/38/d399e70fcfc880a70ae02551a68cfb1b3663d59850943f6e711ab19d3648/asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22", size = 2654513 }, + { url = "https://files.pythonhosted.org/packages/1f/fb/e5b798ff0d6aceda7067dad9dbf1a11016ef7c8d0117d75f031a39f5ed1e/asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610", size = 2677142 }, + { url = "https://files.pythonhosted.org/packages/d5/98/314ccb06cf587656da2c58afb57b4ff3ddd661108db568c16c181af40436/asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397", size = 3206559 }, + { url = "https://files.pythonhosted.org/packages/7e/ca/aad32992a1d38ff568e11be44d9b45942b48d50d3647f7b421f62fd99ef3/asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb", size = 3237148 }, + { url = "https://files.pythonhosted.org/packages/6d/66/0d26bebcb6794bb49cdd0104deba38cb8deed5d86196afb6f6366c03ee4e/asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449", size = 503268 }, + { url = "https://files.pythonhosted.org/packages/a6/05/fed8ceefaef48dda4a24572906b2931b4bf5b20d037d2fc6b6f66f284439/asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772", size = 553053 }, + { url = "https://files.pythonhosted.org/packages/69/28/3e3c4e243778f0361214b9d6e8bc6aa8e8bf55f35a2d2cb8949a6863caab/asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4", size = 653061 }, + { url = "https://files.pythonhosted.org/packages/4a/13/f96284d7014dd06db2e78bea15706443d7895548bf74cf34f0c3ee1863fd/asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac", size = 638740 }, + { url = "https://files.pythonhosted.org/packages/27/25/d140bd503932f99528edc0a1461648973ad3c1c67f5929d11f3e8b5f81f4/asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870", size = 2788952 }, + { url = "https://files.pythonhosted.org/packages/c4/41/a0bdc18f13bdd5f27e7fc1b5de7e1caae19951967c109bca1a2e99cf3331/asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f", size = 2809108 }, + { url = "https://files.pythonhosted.org/packages/f2/1f/1737248d7b1b75d19e7f07a98321bc58cb6fc979754c78544cfebff3359b/asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23", size = 3355924 }, + { url = "https://files.pythonhosted.org/packages/88/b0/6bebd69ed484055d47b78ea34fd9887c35694b63c9a648a7f02759d3bf73/asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b", size = 3391360 }, + { url = "https://files.pythonhosted.org/packages/5b/89/3ed6e9d235f8aa13aa8ee8dc3a70f754962dbd441bec2dcfdae9f9e0e2e3/asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675", size = 496216 }, + { url = "https://files.pythonhosted.org/packages/f2/39/f7e755b5d5aa59d8385c08be58726aceffc1da9360041031554d664c783f/asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3", size = 543321 }, + { url = "https://files.pythonhosted.org/packages/74/49/243b77ff7ac7c11ec771ce0d76df20e7af73ea92c0399bd480ef794ce946/asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da", size = 686978 }, + { url = "https://files.pythonhosted.org/packages/6b/4d/ee74620647766e67fb6fe88554e49874eb74e34903a2b6c32319cb97e271/asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3", size = 665009 }, + { url = "https://files.pythonhosted.org/packages/4d/53/0b9e8f09a87cb764fa8e99c3ff3c6286ef7f29a92782e5667a38032e23d1/asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090", size = 2754493 }, + { url = "https://files.pythonhosted.org/packages/16/48/e0c950af52a21fe71f0e0ba71830511e78cd455957fe2d25badf89ad12a8/asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83", size = 2782828 }, + { url = "https://files.pythonhosted.org/packages/be/a3/d6002935c546829d9d2138d1bc1929a596e489a35c147d7ed68a496c54d3/asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810", size = 3291150 }, + { url = "https://files.pythonhosted.org/packages/db/06/e388331eed46eaea42a73d38b8aa8f624a2e6ca426f03d4a423bd97d823f/asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c", size = 3309783 }, + { url = "https://files.pythonhosted.org/packages/dd/29/980c4401aeb7d3ddff24453fae555cc422b84bdb907c8316f7cd07eab924/asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2", size = 515864 }, + { url = "https://files.pythonhosted.org/packages/6d/64/89d970c41baeece88b4123e0b53e13ef855596e33aeb060031cd9b720a40/asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8", size = 567186 }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, + { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593 }, + { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000 }, + { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963 }, + { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + +[[package]] +name = "bleach" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/d5f220ca638f6a25557a611860482cb6e54b2d97f0332966b1b005742e1f/bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", size = 201298 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/e2/dfcab68c9b2e7800c8f06b85c76e5f978d05b195a958daa9b1dda54a1db6/bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4", size = 162493 }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "cachetools" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8b/8e2ebf5ee26c21504de5ea2fb29cc6ae612b35fd05f959cdb641feb94ec4/cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b", size = 27985 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c9/c8a7710f2cedcb1db9224fdd4d8307c9e48cbddc46c18b515fefc0f1abbe/cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", size = 9288 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818 }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649 }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045 }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471 }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317 }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368 }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491 }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695 }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849 }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091 }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445 }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782 }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671 }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744 }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993 }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382 }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536 }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349 }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365 }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499 }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735 }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786 }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436 }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379 }, + { url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814 }, + { url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937 }, + { url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986 }, + { url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896 }, + { url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613 }, + { url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909 }, + { url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948 }, + { url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844 }, + { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493 }, + { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921 }, + { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556 }, + { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245 }, + { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032 }, + { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679 }, + { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852 }, + { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389 }, + { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997 }, + { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911 }, + { url = "https://files.pythonhosted.org/packages/60/0c/5da94be095239814bf2730a28cffbc48d6df4304e044f80d39e1ae581997/coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f", size = 211377 }, + { url = "https://files.pythonhosted.org/packages/d5/cb/b9e93ebf193a0bb89dbcd4f73d7b0e6ecb7c1b6c016671950e25f041835e/coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", size = 211803 }, + { url = "https://files.pythonhosted.org/packages/78/1a/cdbfe9e1bb14d3afcaf6bb6e1b9ba76c72666e329cd06865bbd241efd652/coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", size = 240561 }, + { url = "https://files.pythonhosted.org/packages/59/04/57f1223f26ac018d7ce791bfa65b0c29282de3e041c1cd3ed430cfeac5a5/coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", size = 238488 }, + { url = "https://files.pythonhosted.org/packages/b7/b1/0f25516ae2a35e265868670384feebe64e7857d9cffeeb3887b0197e2ba2/coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", size = 239589 }, + { url = "https://files.pythonhosted.org/packages/e0/a4/99d88baac0d1d5a46ceef2dd687aac08fffa8795e4c3e71b6f6c78e14482/coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", size = 239366 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/1db89e135feb827a868ed15f8fc857160757f9cab140ffee21342c783ceb/coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", size = 237591 }, + { url = "https://files.pythonhosted.org/packages/1b/6d/ac4d6fdfd0e201bc82d1b08adfacb1e34b40d21a22cdd62cfaf3c1828566/coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", size = 238572 }, + { url = "https://files.pythonhosted.org/packages/25/5e/917cbe617c230f7f1745b6a13e780a3a1cd1cf328dbcd0fd8d7ec52858cd/coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", size = 213966 }, + { url = "https://files.pythonhosted.org/packages/bd/93/72b434fe550135869f9ea88dd36068af19afce666db576e059e75177e813/coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", size = 214852 }, + { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +] + +[[package]] +name = "databases" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/de/ea55722907bd1b2389b01e362faa3c66a09d5a8463c13d44c80da7b32497/databases-0.9.0.tar.gz", hash = "sha256:d2f259677609bf187737644c95fa41701072e995dfeb8d2882f335795c5b61b0", size = 31084 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/43/6035922af5471f1a196851831a1d5f403447401b395f474bf673efa8959f/databases-0.9.0-py3-none-any.whl", hash = "sha256:9ee657c9863b34f8d3a06c06eafbe1bda68af2a434b56996312edf1f1c0b6297", size = 25580 }, +] + +[[package]] +name = "debugpy" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/c7/a18e15ed2e53f86de2e1c4162a54ddf1c4f4cee5ca40270c14725ccdd8ff/debugpy-1.8.1.zip", hash = "sha256:f696d6be15be87aef621917585f9bb94b1dc9e8aced570db1b8a6fc14e8f9b42", size = 4619053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/00/629fd2ba18483496482fd4b640c59b904238472c004036f331729753c63c/debugpy-1.8.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:3bda0f1e943d386cc7a0e71bfa59f4137909e2ed947fb3946c506e113000f741", size = 1714331 }, + { url = "https://files.pythonhosted.org/packages/7a/27/78d5cf9c7aba43f8341e78273ab776913d2d33beb581ec39b65e56a0db77/debugpy-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dda73bf69ea479c8577a0448f8c707691152e6c4de7f0c4dec5a4bc11dee516e", size = 2998681 }, + { url = "https://files.pythonhosted.org/packages/ee/28/69b62b9e21d0e8d5ad45e5c0323f921a00ccc436bf144940b084fb898d86/debugpy-1.8.1-cp310-cp310-win32.whl", hash = "sha256:3a79c6f62adef994b2dbe9fc2cc9cc3864a23575b6e387339ab739873bea53d0", size = 4771701 }, + { url = "https://files.pythonhosted.org/packages/a2/81/408eecc856b931b4db262bd3402534d815e22dc7ebcfdc333cb57e69e9f9/debugpy-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:7eb7bd2b56ea3bedb009616d9e2f64aab8fc7000d481faec3cd26c98a964bcdd", size = 4796491 }, + { url = "https://files.pythonhosted.org/packages/1d/04/ce7170a5094fe5943b11925d3e11ae7ee6c5c79166f0b0298420995ea9cc/debugpy-1.8.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:016a9fcfc2c6b57f939673c874310d8581d51a0fe0858e7fac4e240c5eb743cb", size = 1783713 }, + { url = "https://files.pythonhosted.org/packages/da/65/ec4d2e244ec7f9db0b5250649f3ae6fc6a9df7d4da2c4bcdc46778d9a674/debugpy-1.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd97ed11a4c7f6d042d320ce03d83b20c3fb40da892f994bc041bbc415d7a099", size = 2643137 }, + { url = "https://files.pythonhosted.org/packages/0c/27/6dac9f0c3437c992b05375b2fa91d2136e50ea77c5923c58eef6a44e43aa/debugpy-1.8.1-cp311-cp311-win32.whl", hash = "sha256:0de56aba8249c28a300bdb0672a9b94785074eb82eb672db66c8144fff673146", size = 4708784 }, + { url = "https://files.pythonhosted.org/packages/2a/0e/b6ca28f1a0c86c601c9253e71dbfc2f6acbda6930e53f3295b02911bd5ae/debugpy-1.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a9fe0829c2b854757b4fd0a338d93bc17249a3bf69ecf765c61d4c522bb92a8", size = 4723449 }, + { url = "https://files.pythonhosted.org/packages/9a/c8/bd400e1d1d4d3758297d7d96df9f1e16bf835282053681724ca2a644fe6e/debugpy-1.8.1-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:bfb20cb57486c8e4793d41996652e5a6a885b4d9175dd369045dad59eaacea42", size = 1723704 }, + { url = "https://files.pythonhosted.org/packages/ba/2c/69244a26dc484b37db8fc76ba5af107d501d0e45be27b3042b74aa332aa4/debugpy-1.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd3fdd3f67a7e576dd869c184c5dd71d9aaa36ded271939da352880c012e703", size = 3065302 }, + { url = "https://files.pythonhosted.org/packages/a4/aa/0598cce781e6d829a0123a539312688d41011654cd64a88ec2d61a2ac1d2/debugpy-1.8.1-cp39-cp39-win32.whl", hash = "sha256:58911e8521ca0c785ac7a0539f1e77e0ce2df753f786188f382229278b4cdf23", size = 4780994 }, + { url = "https://files.pythonhosted.org/packages/dc/33/22e2b1c4adab01e08381032fe2b3c7453f460bcf92882f31bddf7b908f1e/debugpy-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:6df9aa9599eb05ca179fb0b810282255202a66835c6efb1d112d21ecb830ddd3", size = 4807930 }, + { url = "https://files.pythonhosted.org/packages/57/ab/6df7e24db51e1db642a5ea1759d44fb656251253995a27deb37af9b192ae/debugpy-1.8.1-py2.py3-none-any.whl", hash = "sha256:28acbe2241222b87e255260c76741e1fbf04fdc3b6d094fcf57b6c6f75ce1242", size = 4832569 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "fastapi" +version = "0.108.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/0d/ec01761249b228f288b693a7ad6b2e695af521ca0f32dd09aef58890d9a4/fastapi-0.108.0.tar.gz", hash = "sha256:5056e504ac6395bf68493d71fcfc5352fdbd5fda6f88c21f6420d80d81163296", size = 11417797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e0/d5d6482e992a1892f3a9a62f6a9154944ae5b276e7da1cf92faa02e3a107/fastapi-0.108.0-py3-none-any.whl", hash = "sha256:8c7bc6d315da963ee4cdb605557827071a9a7f95aeb8fcdd3bde48cdc8764dd7", size = 92045 }, +] + +[[package]] +name = "fastapi-mail" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosmtplib" }, + { name = "blinker" }, + { name = "email-validator" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/94/9fc53e33436368973071979bdb8cc31455e67daed4fbc301885459853d53/fastapi_mail-1.4.1.tar.gz", hash = "sha256:9095b713bd9d3abb02fe6d7abb637502aaf680b52e177d60f96273ef6bc8bb70", size = 13301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/a3/c94fac41f430dd1a5586061aed04c5f1f6478b488a581d0b7c0b7c95ea76/fastapi_mail-1.4.1-py3-none-any.whl", hash = "sha256:fa5ef23b2dea4d3ba4587f4bbb53f8f15274124998fb4e40629b3b636c76c398", size = 14736 }, +] + +[[package]] +name = "flake8" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c4/5842fc9fc94584c455543540af62fd9900faade32511fab650e9891ec225/flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426", size = 48177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/5c/0627be4c9976d56b1217cb5187b7504e7fd7d3503f8bfd312a04077bd4f7/flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", size = 57786 }, +] + +[[package]] +name = "geoalchemy2" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/35/85271b45e0f49c35e61cff36b437d2f8f5aa705840ec402b3556e0d52b91/GeoAlchemy2-0.14.3.tar.gz", hash = "sha256:79c432b10dd8c48422f794eaf9a1200929de14f41d2396923bfe92f4c6abaf89", size = 222523 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/b2/35d5308bb943d91737addc86305d6859006e4923a8979bfe2f1a4e05269c/GeoAlchemy2-0.14.3-py3-none-any.whl", hash = "sha256:a727198394fcc4760a27c4c5bff8b9f4f79324ec2dd98c4c1b8a7026b8918d81", size = 72934 }, +] + +[[package]] +name = "geojson" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/34/0ea653dec93d3a360856e629a897a1d3ab534f2952852bb59d55853055ed/geojson-3.1.0.tar.gz", hash = "sha256:58a7fa40727ea058efc28b0e9ff0099eadf6d0965e04690830208d3ef571adac", size = 24492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/1b/4f57660aa148d3e3043d048b7e1ab87dfeb85204d0fdb5b4e19c08202162/geojson-3.1.0-py3-none-any.whl", hash = "sha256:68a9771827237adb8c0c71f8527509c8f5bef61733aa434cefc9c9d4f0ebe8f3", size = 15044 }, +] + +[[package]] +name = "gevent" +version = "23.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b0/f61effe3729bd336f7f1c152c3892960fc56d712c648bdcb0d133f3823fc/gevent-23.9.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1b9ff212cddf245d44651600970c5370c4168f710d4856d8b2350d734dfff1a6", size = 2927042 }, + { url = "https://files.pythonhosted.org/packages/4a/cd/c0ff254ae11793c5c5a6157bfb2b8517cd6eef76f1945a169af5aa4913ac/gevent-23.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03d7452ec45c71ef84cacbf199dac582af2948fb37c638aa6e7232fe5c2840f1", size = 4819888 }, + { url = "https://files.pythonhosted.org/packages/b3/75/3583e7dff38b03209e17f0ecb227ee624adb7a40fc3d0f15219341458bca/gevent-23.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41199f10877f368822058b47aca3276e5a4c453e2c4df814a44f23c1bf059488", size = 4970632 }, + { url = "https://files.pythonhosted.org/packages/e2/b9/2dc5c1e42281bbebb81931e7fb63b8d075944cc6326375574884e486181d/gevent-23.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3778609919477ae6eccbf3b10308015f5620c428dbf0dbc6cbeb8a135c9d603a", size = 5051343 }, + { url = "https://files.pythonhosted.org/packages/95/16/ea3b0f80f50b5b9a5da8c8efbddc5729f0262738a6901b6c04cbb3ac6700/gevent-23.9.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2894d82111ed27b7f80dcbf8c713bb9c9c2c7e8890606cf548f6320fb2ae328f", size = 6366957 }, + { url = "https://files.pythonhosted.org/packages/5c/b0/8e593bcdcbe363e712dd4afa46b75b06ad6d79e0732296363493e8862b08/gevent-23.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1578b5c6da4e88f5cade5682b13635d17231ade6c549d8498cc95776954675c4", size = 5311665 }, + { url = "https://files.pythonhosted.org/packages/c4/20/478ab23795020853bfd1704a8307fb82344f52fe90253930abc9cd41c208/gevent-23.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1ecd70495749ba1ece8e312e41e98d3371f5aa1e98922b47b3635c8263157938", size = 6547794 }, + { url = "https://files.pythonhosted.org/packages/c3/55/f037f2cbf495b9e34d33fa8166eb1eb7751ec92bb89aa6620b08e48fc819/gevent-23.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:259438ec75b31b199d99941294e50f705d92d20d59578244a882a57c2f0f52b3", size = 2939588 }, + { url = "https://files.pythonhosted.org/packages/a1/81/d8f808fa7bfd98c0bf3b555a6614ff261ee9ab2e78f10c38f4bf321128c5/gevent-23.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6eecefb35ceaa33693b971d18e98bfc7e63c9488f1b83eddedb8087a567066", size = 4898638 }, + { url = "https://files.pythonhosted.org/packages/6c/b0/aaed19f2b2dbe11706b6b4d763e05adb534433f447afafaa1cce195100a5/gevent-23.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17abeca1a8b61a0ce135127f8328265a317efaa3eda047199d65129ca68f0268", size = 5059971 }, + { url = "https://files.pythonhosted.org/packages/ed/48/fdcd1293e0178fd0754683cf9bfc67688425c114780779cac01dac9add81/gevent-23.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06a927940f9348b504888f93c8d85c2d8e2277ffd2105734148c0082ade17c20", size = 5118792 }, + { url = "https://files.pythonhosted.org/packages/34/32/931efaa9dad2c1c28cbecbcd91c792e73f84fe470d065c4b53d72b0bb731/gevent-23.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:96a3ce33e77277995436656e6f7712f4dcbe9dafa527f245ebbe3de44fd3d4bb", size = 6526236 }, + { url = "https://files.pythonhosted.org/packages/70/bd/18ef2b1cb3928f7e327f488afff0a093531d7c46c7f69cb08b6a700d1aa1/gevent-23.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:169784ef1594ddac0a773e85e5f996f6f8512abdbe248bff16ed8a22fafecadb", size = 5465877 }, + { url = "https://files.pythonhosted.org/packages/62/f5/3309f1de79d6def9461a491f5d6de230f6f60274bd5dc2440973b32b9851/gevent-23.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faad7ed513672083eabe1e45d1dbfbbcd7bde9c0da6ef3598e975128c8aee427", size = 6591676 }, + { url = "https://files.pythonhosted.org/packages/13/4b/b52d1c7ef6738b60af27ef166861ff1118d9ae240cd9d41255d58cc7a1ba/gevent-23.9.0-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:951d95f1066fb84f661f2bb2c1d6423855a63922abf3e5bd1001711cfb1d8f38", size = 2943719 }, + { url = "https://files.pythonhosted.org/packages/bb/f5/cf73c13e82ad99ee3bb41b542539d2a35f2a770f0f4603c8bca954af0321/gevent-23.9.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:d55cf22d6b29593d256bad9fade978fbdc998d6f3bf3aa3399a81feaa91b5a86", size = 6394567 }, + { url = "https://files.pythonhosted.org/packages/84/ba/e561b5564f3d2a705d1e2df6f88ef21463adc6d0138608ed61fd510aaac4/gevent-23.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5afcf6402981f33c84567e6a0a8dcf05e52e8c0b03d80b699644c5f251221e02", size = 6577186 }, + { url = "https://files.pythonhosted.org/packages/7e/20/cad6c12ad933babbabe4a01ce4ae05b235f293455bf0658c7a2aabee37d4/gevent-23.9.0-cp39-cp39-win32.whl", hash = "sha256:91e1e96d50dc4a296dce77b9e28c29a7401fbaca6809989b2029266db6172967", size = 1453385 }, +] + +[[package]] +name = "greenlet" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/1e/632e55a04d732c8184201238d911207682b119c35cecbb9a573a6c566731/greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", size = 164980 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/27/ca6f6deccf2bf7dce5c50953d354d22743f9e2bbce36815f31966687a4d1/greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", size = 197506 }, + { url = "https://files.pythonhosted.org/packages/0a/46/96b37dcfe9c9d39b2d2f060a5775139ce8a440315a1ca2667a6b83a2860e/greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", size = 242120 }, + { url = "https://files.pythonhosted.org/packages/c5/ab/a69a875a45474cc5776b879258bfa685e99aae992ab310a0b8f773fe56a0/greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", size = 608292 }, + { url = "https://files.pythonhosted.org/packages/cd/e8/1ebc8f07d795c3677247e37dae23463a655636a4be387b0d739fa8fd9b2f/greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", size = 622726 }, + { url = "https://files.pythonhosted.org/packages/6e/11/a1f1af20b6a1a8069bc75012569d030acb89fd7ef70f888b6af2f85accc6/greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", size = 613701 }, + { url = "https://files.pythonhosted.org/packages/c4/92/bbd9373fb022c21d1c41bc74b043d8d007825f80bb9534f0dd2f7ed62bca/greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", size = 1134345 }, + { url = "https://files.pythonhosted.org/packages/17/f9/7f5d755380d329e44307c2f6e52096740fdebb92e7e22516811aeae0aec0/greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", size = 1156603 }, + { url = "https://files.pythonhosted.org/packages/53/0f/637f6e18e1980ebd2eedd8a9918a7898a6fe44f6188f6f39c6d9181c9891/greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", size = 192171 }, + { url = "https://files.pythonhosted.org/packages/a8/7a/5542d863a91b3309585219bae7d97aa82fe0482499a840c100297262ec8f/greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", size = 243039 }, + { url = "https://files.pythonhosted.org/packages/03/1a/dae7e4abc978e3eff4b8e90e76fb6619ba38d3cabac5f10131880fc03091/greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", size = 197881 }, + { url = "https://files.pythonhosted.org/packages/e8/3a/ebc4fa1e813ae1fa718eb88417c31587e2efb743ed5f6ff0ae066502c349/greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", size = 612431 }, + { url = "https://files.pythonhosted.org/packages/6b/2f/1cb3f376df561c95cb61b199676f51251f991699e325a2aa5e12693d10b8/greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", size = 627807 }, + { url = "https://files.pythonhosted.org/packages/86/8d/3a18311306830f6db5f5676a1cb8082c8943bfa6c928b40006e5358170fc/greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", size = 618820 }, + { url = "https://files.pythonhosted.org/packages/f0/2e/20eab0fa6353a08b0de055dd54e2575a6869ee693d86387076430475832d/greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", size = 1138292 }, + { url = "https://files.pythonhosted.org/packages/71/c5/c26840ce91bcbbfc42c1a246289d9d4c758663652669f24e37f84bcdae2a/greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", size = 1161342 }, + { url = "https://files.pythonhosted.org/packages/7e/a6/0a34cde83fe520fa4e8192a1bc0fc7bf9f755215fefe3f42c9b97c45c620/greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", size = 192458 }, + { url = "https://files.pythonhosted.org/packages/3f/1a/1a48b85490d93af5c577e6ab4d032ee3fe85c4c6d8656376f28d6d403fb1/greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", size = 196583 }, + { url = "https://files.pythonhosted.org/packages/f6/04/74e97d545f9276dee994b959eab3f7d70d77588e5aaedc383d15b0057acd/greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", size = 241407 }, + { url = "https://files.pythonhosted.org/packages/1d/a0/697653ea5e35acaf28e2a1246645ac512beb9b49a86b310fd0151b075342/greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", size = 586061 }, + { url = "https://files.pythonhosted.org/packages/09/57/5fdd37939e0989a756a32d0a838409b68d1c5d348115e9c697f42ee4f87d/greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", size = 607621 }, + { url = "https://files.pythonhosted.org/packages/49/b8/3ee1723978245e6f0c087908689f424876803ec05555400681240ab2ab33/greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", size = 618038 }, + { url = "https://files.pythonhosted.org/packages/e9/29/2ae545c4c0218b042c2bb0760c0f65e114cca1ab5e552dc23b0f118e428a/greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", size = 610882 }, + { url = "https://files.pythonhosted.org/packages/20/28/c93ffaa75f3c907cd010bf44c5c18c7f8f4bb2409146bd67d538163e33b8/greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", size = 1130269 }, + { url = "https://files.pythonhosted.org/packages/4d/b2/32f737e1fcf67b23351b4860489029df562b41d7ffb568a3e1ae610f7a9b/greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", size = 1155902 }, + { url = "https://files.pythonhosted.org/packages/09/93/d7ed73f82b6f1045dd5d98f063fa16da5273d0812c42f38229d28882762b/greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", size = 188218 }, + { url = "https://files.pythonhosted.org/packages/b3/89/1d3b78577a6b2762cb254f6ce5faec9b7c7b23052d1cdb7237273ff37d10/greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", size = 192065 }, +] + +[[package]] +name = "gunicorn" +version = "20.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/0d1f0296485a6af03366604142ea8f19f0833894db3512a40ed07b2a56dd/gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8", size = 370601 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/dd/5b190393e6066286773a67dfcc2f9492058e9b57c4867a95f1ba5caf0a83/gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", size = 79531 }, +] + +[package.optional-dependencies] +gevent = [ + { name = "gevent" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780 }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297 }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130 }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949 }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591 }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344 }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029 }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492 }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891 }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788 }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120 }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565 }, + { url = "https://files.pythonhosted.org/packages/51/b1/4fc6f52afdf93b7c4304e21f6add9e981e4f857c2fa622a55dfe21b6059e/httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003", size = 201123 }, + { url = "https://files.pythonhosted.org/packages/c2/01/e6ecb40ac8fdfb76607c7d3b74a41b464458d5c8710534d8f163b0c15f29/httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab", size = 104507 }, + { url = "https://files.pythonhosted.org/packages/dc/24/c70c34119d209bf08199d938dc9c69164f585ed3029237b4bdb90f673cb9/httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547", size = 449615 }, + { url = "https://files.pythonhosted.org/packages/2b/62/e7f317fed3703bd81053840cacba4e40bcf424b870e4197f94bd1cf9fe7a/httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9", size = 448819 }, + { url = "https://files.pythonhosted.org/packages/2a/13/68337d3be6b023260139434c49d7aa466aaa98f9aee7ed29270ac7dde6a2/httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076", size = 422093 }, + { url = "https://files.pythonhosted.org/packages/fc/b3/3a1bc45be03dda7a60c7858e55b6cd0489a81613c1908fb81cf21d34ae50/httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd", size = 423898 }, + { url = "https://files.pythonhosted.org/packages/05/72/2ddc2ae5f7ace986f7e68a326215b2e7c32e32fd40e6428fa8f1d8065c7e/httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6", size = 89552 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/44/ae06b446b8d8263d712a211e959212083a5eda2bf36d57ca7415e03f6f36/importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743", size = 53494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/37/db7ba97e676af155f5fcb1a35466f446eadc9104e25b83366e8088c9c926/importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", size = 22933 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/a1/d3fb83e7a61fa0c0d3d08ad0a94ddbeff3731c05212617dff3a94e097f08/itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a", size = 56143 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", size = 15749 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "joblib" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/08/8bd4a0250247861420a040b33ccf42f43c426ac91d99405374ef117e5872/joblib-1.5.0.tar.gz", hash = "sha256:d8757f955389a3dd7a23152e43bc297c2e0c2d3060056dad0feefc88a06939b5", size = 330234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d3/13ee227a148af1c693654932b8b0b02ed64af5e1f7406d56b088b57574cd/joblib-1.5.0-py3-none-any.whl", hash = "sha256:206144b320246485b712fc8cc51f017de58225fa8b414a1fe1764a7231aca491", size = 307682 }, +] + +[[package]] +name = "loguru" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549 }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, +] + +[[package]] +name = "markdown" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/2a/62841f4fb1fef5fa015ded48d02401cd95643ca03b6760b29437b62a04a4/Markdown-3.4.4.tar.gz", hash = "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6", size = 324459 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/b5/228c1cdcfe138f1a8e01ab1b54284c8b83735476cb22b6ba251656ed13ad/Markdown-3.4.4-py3-none-any.whl", hash = "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941", size = 94174 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "newrelic" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/73/9c78820ecf2da85b30beca9f926a29cfd05c6cac7df2d9e05991b9c85734/newrelic-8.8.0.tar.gz", hash = "sha256:84d1f71284efa5f1cae696161e0c3cb65eaa2f53116fe5e7c5a62be7d15d9536", size = 919778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/aa/208f4eedd3d5e750bbbd1a6e50848104be440db6d115f8a9d883c117aca1/newrelic-8.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b4db0e7544232d4e6e835a02ee28637970576f8dce82ffcaa3d675246e822d5", size = 752214 }, + { url = "https://files.pythonhosted.org/packages/44/3f/8a5cd001ab5a2dc0f6416afbdeabfb988912f2c345661ec737be256b757b/newrelic-8.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9355f209ba8d82fd0f9d78d7cc1d9bef0ae4677b3cfed7b7aaec521adbe87559", size = 750707 }, + { url = "https://files.pythonhosted.org/packages/7e/47/515de1ee4f706188b7ee376bdeac96d4358bff976b97842d98ffe80f2ff4/newrelic-8.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4a0556c6ece49132ab1c32bfe398047a8311f9a8b6862b482495d132fcb0ad4", size = 752756 }, + { url = "https://files.pythonhosted.org/packages/06/eb/9226b7d12a768ba6e8f2372733c66b4146b3bcfa30f2a75a699b9997934e/newrelic-8.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caccdf201735df80b470ddf772f60a154f2c07c0c1b2b3f6e999d55e79ce601e", size = 751279 }, + { url = "https://files.pythonhosted.org/packages/57/47/3f79edeae09ee5b53eb6d215102ba84764ba3af5afc5acd7d74984169d0b/newrelic-8.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:796ed5ff44b04b41e051dc0112e5016e53a37e39e95023c45ff7ecd34c254a7d", size = 751805 }, + { url = "https://files.pythonhosted.org/packages/80/3a/faa4137e8ce946ac75a9b27cad2d7dd09306fdf6a656f8c8d06e49db7688/newrelic-8.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcd3219e1e816a0fdb51ac993cac6744e6a835c13ee72e21d86bcbc2d16628ce", size = 750283 }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005 }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297 }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567 }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913 }, + { url = "https://files.pythonhosted.org/packages/7d/24/ce71dc08f06534269f66e73c04f5709ee024a1afe92a7b6e1d73f158e1f8/numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c", size = 20636301 }, + { url = "https://files.pythonhosted.org/packages/ae/8c/ab03a7c25741f9ebc92684a20125fbc9fc1b8e1e700beb9197d750fdff88/numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be", size = 13971216 }, + { url = "https://files.pythonhosted.org/packages/6d/64/c3bcdf822269421d85fe0d64ba972003f9bb4aa9a419da64b86856c9961f/numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764", size = 14226281 }, + { url = "https://files.pythonhosted.org/packages/54/30/c2a907b9443cf42b90c17ad10c1e8fa801975f01cb9764f3f8eb8aea638b/numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", size = 18249516 }, + { url = "https://files.pythonhosted.org/packages/43/12/01a563fc44c07095996d0129b8899daf89e4742146f7044cdbdb3101c57f/numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd", size = 13882132 }, + { url = "https://files.pythonhosted.org/packages/16/ee/9df80b06680aaa23fc6c31211387e0db349e0e36d6a63ba3bd78c5acdf11/numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c", size = 18084181 }, + { url = "https://files.pythonhosted.org/packages/28/7d/4b92e2fe20b214ffca36107f1a3e75ef4c488430e64de2d9af5db3a4637d/numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6", size = 5976360 }, + { url = "https://files.pythonhosted.org/packages/b5/42/054082bd8220bbf6f297f982f0a8f5479fcbc55c8b511d928df07b965869/numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea", size = 15814633 }, + { url = "https://files.pythonhosted.org/packages/3f/72/3df6c1c06fc83d9cfe381cccb4be2532bbd38bf93fbc9fad087b6687f1c0/numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30", size = 20455961 }, + { url = "https://files.pythonhosted.org/packages/8e/02/570545bac308b58ffb21adda0f4e220ba716fb658a63c151daecc3293350/numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c", size = 18061071 }, + { url = "https://files.pythonhosted.org/packages/f4/5f/fafd8c51235f60d49f7a88e2275e13971e90555b67da52dd6416caec32fe/numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0", size = 15709730 }, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pandas" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/d9/ecf715f34c73ccb1d8ceb82fc01cd1028a65a5f6dbc57bfa6ea155119058/pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54", size = 4398391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/39600d073ea70b9cafdc51fab91d69c72b49dd92810f24cb5ac6631f387f/pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce", size = 12551798 }, + { url = "https://files.pythonhosted.org/packages/fd/4b/0cd38e68ab690b9df8ef90cba625bf3f93b82d1c719703b8e1b333b2c72d/pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238", size = 11287392 }, + { url = "https://files.pythonhosted.org/packages/01/c6/d3d2612aea9b9f28e79a30b864835dad8f542dcf474eee09afeee5d15d75/pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08", size = 15634823 }, + { url = "https://files.pythonhosted.org/packages/89/1b/12521efcbc6058e2673583bb096c2b5046a9df39bd73eca392c1efed24e5/pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0", size = 13032214 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/303dba73f1c3a9ef067d23e5afbb6175aa25e8121be79be354dcc740921a/pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51", size = 16278302 }, + { url = "https://files.pythonhosted.org/packages/ba/df/8ff7c5ed1cc4da8c6ab674dc8e4860a4310c3880df1283e01bac27a4333d/pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99", size = 13892866 }, + { url = "https://files.pythonhosted.org/packages/69/a6/81d5dc9a612cf0c1810c2ebc4f2afddb900382276522b18d128213faeae3/pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772", size = 11621592 }, + { url = "https://files.pythonhosted.org/packages/1b/70/61704497903d43043e288017cb2b82155c0d41e15f5c17807920877b45c2/pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288", size = 12574808 }, + { url = "https://files.pythonhosted.org/packages/16/c6/75231fd47afd6b3f89011e7077f1a3958441264aca7ae9ff596e3276a5d0/pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151", size = 11304876 }, + { url = "https://files.pythonhosted.org/packages/97/2d/7b54f80b93379ff94afb3bd9b0cd1d17b48183a0d6f98045bc01ce1e06a7/pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b", size = 15602548 }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4d82be566f069d7a9a702dcdf6f9106df0e0b042e738043c0cc7ddd7e3f6/pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee", size = 13031332 }, + { url = "https://files.pythonhosted.org/packages/92/a2/b79c48f530673567805e607712b29814b47dcaf0d167e87145eb4b0118c6/pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db", size = 16286054 }, + { url = "https://files.pythonhosted.org/packages/40/c7/47e94907f1d8fdb4868d61bd6c93d57b3784a964d52691b77ebfdb062842/pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1", size = 13879507 }, + { url = "https://files.pythonhosted.org/packages/ab/63/966db1321a0ad55df1d1fe51505d2cdae191b84c907974873817b0a6e849/pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24", size = 11634249 }, + { url = "https://files.pythonhosted.org/packages/1b/cc/eb6ce83667131667c6561e009823e72aa5c76698e75552724bdfc8d1ef0b/pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2", size = 12566406 }, + { url = "https://files.pythonhosted.org/packages/96/08/9ad65176f854fd5eb806a27da6e8b6c12d5ddae7ef3bd80d8b3009099333/pandas-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd", size = 11304008 }, + { url = "https://files.pythonhosted.org/packages/aa/30/5987c82fea318ac7d6bcd083c5b5259d4000e99dd29ae7a9357c65a1b17a/pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863", size = 15662279 }, + { url = "https://files.pythonhosted.org/packages/bb/30/f6f1f1ac36250f50c421b1b6af08c35e5a8b5a84385ef928625336b93e6f/pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921", size = 13069490 }, + { url = "https://files.pythonhosted.org/packages/b5/27/76c1509f505d1f4cb65839352d099c90a13019371e90347166811aa6a075/pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a", size = 16299412 }, + { url = "https://files.pythonhosted.org/packages/5d/11/a5a2f52936fba3afc42de35b19cae941284d973649cb6949bc41cc2e5901/pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57", size = 13920884 }, + { url = "https://files.pythonhosted.org/packages/bf/2c/a0cee9c392a4c9227b835af27f9260582b994f9a2b5ec23993b596e5deb7/pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4", size = 11637580 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pycodestyle" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/6e/1f4a62078e4d95d82367f24e685aef3a672abfd27d1a868068fed4ed2254/pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae", size = 39312 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/be/b00116df1bfb3e0bb5b45e29d604799f7b91dd861637e4d448b4e09e6a3e/pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", size = 31424 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/3f/56142232152145ecbee663d70a19a45d078180633321efb3847d2562b490/pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a", size = 651797 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b7/9aea7ee6c01fe3f3c03b8ca3c7797c866df5fecece9d6cb27caa138db2e2/pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4", size = 381926 }, +] + +[[package]] +name = "pydantic-core" +version = "2.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/7d/8304d8471cfe4288f95a3065ebda56f9790d087edc356ad5bd83c89e2d79/pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948", size = 360305 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/fc/bfb0da2b2d5b44e49c4c0ce99b07bbfd9f1a4dc92fd3e328a5cf1144467e/pydantic_core-2.14.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9", size = 1864856 }, + { url = "https://files.pythonhosted.org/packages/7d/3a/46913f3134aff44d11edd7bdbba88efe6081f963014e6eaccf83fd8de2d7/pydantic_core-2.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c", size = 1744162 }, + { url = "https://files.pythonhosted.org/packages/8f/2d/919d3642da44bc9d9c60a2e7bbda04633fc3ffbd6768c355ac0d7e2424d7/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4", size = 1832506 }, + { url = "https://files.pythonhosted.org/packages/9b/cd/a2db754b0124e64ad7912160d9c9db310cbd52a990841ef121b53453992d/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534", size = 1855925 }, + { url = "https://files.pythonhosted.org/packages/29/5c/63eb74c7a97daf0ee45dc876f0b0d9cdea9c5c9d64e92508a765cb802e14/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08", size = 2005859 }, + { url = "https://files.pythonhosted.org/packages/5f/0c/3aeafa496aaf656be3682cbcacbfe3b4a4b366aaddac0ea74fb2c7c276a2/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d", size = 2987131 }, + { url = "https://files.pythonhosted.org/packages/90/28/3c6843e6b203999be2660d3f114be196f2182dcac533dc764ad320c9184d/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245", size = 2072940 }, + { url = "https://files.pythonhosted.org/packages/f3/7e/f1c1cf229bd404f5daf972345030f0c205424a326e67ae888c4a5a9066bd/pydantic_core-2.14.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c", size = 1922278 }, + { url = "https://files.pythonhosted.org/packages/bb/32/a2f381c8ae08a9682d4e7943ba1f5b518e6f2bdd8261c23721691b332966/pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66", size = 2012961 }, + { url = "https://files.pythonhosted.org/packages/b3/c5/2accf5bbc145b890454d4eaf8dcd6423d406fc9f64147fd9020618363866/pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590", size = 2138726 }, + { url = "https://files.pythonhosted.org/packages/b4/70/9e4f5624c6d62fe4e0c3199c818c141ab37756987e1e6db965b18d9c344b/pydantic_core-2.14.6-cp310-none-win32.whl", hash = "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7", size = 1731407 }, + { url = "https://files.pythonhosted.org/packages/4b/26/0645f87ed58c9ec41def2268ea1d96d4a436bbcd151fe126b80cb649e49d/pydantic_core-2.14.6-cp310-none-win_amd64.whl", hash = "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87", size = 1891831 }, + { url = "https://files.pythonhosted.org/packages/7d/77/cbfa02b5f46c5ec6be131d97ae93eef883e25d61b4f4d0a058c792b7e3a2/pydantic_core-2.14.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4", size = 1861976 }, + { url = "https://files.pythonhosted.org/packages/fb/17/3e4908cf8cb5a1d189f9dfa7cb5698d945e9a4db6b9138e3fef3c32c1f68/pydantic_core-2.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421", size = 1744008 }, + { url = "https://files.pythonhosted.org/packages/14/53/7844d20be3a334ea46cdcde8a480cf47e31026d4117d7415a0144d7379c9/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b", size = 1830087 }, + { url = "https://files.pythonhosted.org/packages/f1/7b/0fd3444362f31c5f42b655c1ed734480433aa9f8bde97daa19cee0bc2844/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e", size = 1852928 }, + { url = "https://files.pythonhosted.org/packages/24/1d/601f861c0d76154217ea6b066e39f04159a761b9c3a7ca56b0dd0267ce3a/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96", size = 2003799 }, + { url = "https://files.pythonhosted.org/packages/e8/5e/a30d56bb6b19e84bcde76cba2d6df45779f127ec73fa2e6d91f0ad3d4bc2/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b", size = 2985579 }, + { url = "https://files.pythonhosted.org/packages/e7/84/2dc88180fc6f0d13aab2a47a53b89c2dbc239e2a87d0a58e31077e111e82/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1", size = 2071737 }, + { url = "https://files.pythonhosted.org/packages/0d/18/7c17d33b2c8dea2189b2547bafcb70a69a3e537eec12429cc0abfedab683/pydantic_core-2.14.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937", size = 1919929 }, + { url = "https://files.pythonhosted.org/packages/c1/7b/a1cfe9d3fdedf2b33d41960500c17ccba025b483720c79965b73d607687f/pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622", size = 2010042 }, + { url = "https://files.pythonhosted.org/packages/2a/09/c39be628d6068952f30b381576a4392af2024505747572cd70b19f6d9bde/pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2", size = 2135569 }, + { url = "https://files.pythonhosted.org/packages/8a/07/ea362b25882fb0efbe2818011a572a112416903fbc3205b6c5dab3d9695c/pydantic_core-2.14.6-cp311-none-win32.whl", hash = "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2", size = 1730779 }, + { url = "https://files.pythonhosted.org/packages/72/d2/fcb3bc3d6d2fa35387b57e9925f1ff5469c2da634b85061dadbd8c398545/pydantic_core-2.14.6-cp311-none-win_amd64.whl", hash = "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23", size = 1891297 }, + { url = "https://files.pythonhosted.org/packages/20/68/41661007a1436f5f3dea7b9f536f083bbf843c8ebd6a207c36c98b01bde1/pydantic_core-2.14.6-cp311-none-win_arm64.whl", hash = "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6", size = 1849591 }, + { url = "https://files.pythonhosted.org/packages/80/8c/d40937f7f7ccfe9776d1e32b36cebe606da9f11624927bd26722c43ea9cb/pydantic_core-2.14.6-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab", size = 1864545 }, + { url = "https://files.pythonhosted.org/packages/f4/cd/252101e88458f4e7c4d2c44400050f92a0b13960ed3c489b513c97aaa7a6/pydantic_core-2.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f", size = 1731389 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/ab01fa05c9fc885a73357116c494feafe1207035f13848e4a772fc9d6154/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0", size = 1831538 }, + { url = "https://files.pythonhosted.org/packages/3d/cf/d2e97b2bfd0bff7c4e9086fab03956003e906557c9c52941c15fed75152d/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b", size = 1855754 }, + { url = "https://files.pythonhosted.org/packages/93/57/9a77cc69f05f725a2b492a18209a43ba4e8b9ee179d3c27a8b6b3ab2f921/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160", size = 2005450 }, + { url = "https://files.pythonhosted.org/packages/5d/ca/e8fe62da4eb4b538c380900372021c560c3514514677d6d328ac5b95da7c/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b", size = 2988683 }, + { url = "https://files.pythonhosted.org/packages/55/0f/45626f8bf7f7973320531bb384ac302eb9b05a70885b9db2bf1db4cf447b/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab", size = 2072750 }, + { url = "https://files.pythonhosted.org/packages/ce/95/d0bc7df3de0eaad08de467c50d1dc423839864f32e78da1cf57af3bbb2cc/pydantic_core-2.14.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0", size = 1922674 }, + { url = "https://files.pythonhosted.org/packages/ba/09/8078e77e73dda7df0d5cca8541d1fb731a52bc00188806676c3635c344a9/pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9", size = 2013299 }, + { url = "https://files.pythonhosted.org/packages/79/ae/ec8eaa6d9a1305100321d7b9c3c87e015ae61da02a877cfd16b0366b18ff/pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411", size = 2138619 }, + { url = "https://files.pythonhosted.org/packages/98/23/d210c33379ef0525c2cf870a1922b89c11afc3a5b5b2e0485323a383c03a/pydantic_core-2.14.6-cp39-none-win32.whl", hash = "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975", size = 1731133 }, + { url = "https://files.pythonhosted.org/packages/e8/88/a996678cc0a6ed714ccef36c155caacae9b072689c72f1a6f5e62ea62f5b/pydantic_core-2.14.6-cp39-none-win_amd64.whl", hash = "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9", size = 1891877 }, + { url = "https://files.pythonhosted.org/packages/28/1e/04ede6259a552777a859d2d5828aedd540ca0db967641d61be864a49671a/pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95", size = 1862248 }, + { url = "https://files.pythonhosted.org/packages/f9/84/c53d351f926630753b8dcf37ec2edf8b55a5a1724b3edc5104e06d3e54f1/pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277", size = 1734357 }, + { url = "https://files.pythonhosted.org/packages/88/bb/58bd737b1f4a3b567410fd7a55f2e0ed4ba3209bb1a7a35856714a322a04/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670", size = 1821824 }, + { url = "https://files.pythonhosted.org/packages/bc/7f/20ddc4eb15708cc6832c0cc2e398d0fa642aaf28d6ebcbcfb2d284ec6824/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e", size = 1956646 }, + { url = "https://files.pythonhosted.org/packages/13/33/9f761908fde3a6bb10ac865459a6931e53a2cde622782d243365e70981b7/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d", size = 1907843 }, + { url = "https://files.pythonhosted.org/packages/84/e4/da29895abb136eea169944eb81f866d783255c4a6fd581c667c15743b171/pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d", size = 2002448 }, + { url = "https://files.pythonhosted.org/packages/9d/21/32afbed9bfedf916dff87846e10ecd8711ba63c88cd6c9bcfc3297ef22ca/pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94", size = 2131708 }, + { url = "https://files.pythonhosted.org/packages/44/b1/bb98ca320ddc91734839ef12af4afe8ae9710a734a05850225ff1830e7e8/pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66", size = 1985502 }, + { url = "https://files.pythonhosted.org/packages/59/f6/1e7193769d24b32b19139fb875693d7a351af17f10354e7583a0f7b61a49/pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2", size = 1862312 }, + { url = "https://files.pythonhosted.org/packages/fb/ff/812893fd262a98f0291f6afd87a530eb87c75ddc92034b938b8d15aa5ff4/pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03", size = 1734314 }, + { url = "https://files.pythonhosted.org/packages/a2/7e/4af14122c7ea67ad5582fddae56f7827044f6b43cca6c7e7421686cca3de/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f", size = 1821778 }, + { url = "https://files.pythonhosted.org/packages/7f/3d/91a26a7004a57f374d85d837b4b06dde818045ddba34bc19909e04e2a14d/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a", size = 1956642 }, + { url = "https://files.pythonhosted.org/packages/31/76/ee3c136138fbda5f58c3c49371503b42f3a9c678ef284a0b39be17253d78/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf", size = 1907789 }, + { url = "https://files.pythonhosted.org/packages/5c/7a/ceb3c9228ad9ff009ee70fd09ffb9160a45a8adaac5c9a90bc9496a1020e/pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556", size = 2002380 }, + { url = "https://files.pythonhosted.org/packages/e9/b8/5baba04b116546302bc0a07ba0989326a167aeec29fd6f5cadc7deb758b1/pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341", size = 2131726 }, + { url = "https://files.pythonhosted.org/packages/8c/54/0e231a3405827ad8cdc84ae043aa95bfa2cd2d712fc089cb5162dcbbb2e6/pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e", size = 1985447 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/10/664e41fd884d8cd3fa8bcd75a537bd82f540b19d7c0d1ff17eef69a2ffa8/pydantic_settings-2.1.0.tar.gz", hash = "sha256:26b1492e0a24755626ac5e6d715e9077ab7ad4fb5f19a8b7ed7011d52f36141c", size = 31824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/c9/8042368e9a1e6e229b5ec5d88449441a3ee8f8afe09988faeb190af30248/pydantic_settings-2.1.0-py3-none-any.whl", hash = "sha256:7621c0cb5d90d1140d2f0ef557bdf03573aac7035948109adf2574770b77605a", size = 11685 }, +] + +[[package]] +name = "pyflakes" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cc/1df338bd7ed1fa7c317081dcf29bf2f01266603b301e6858856d346a12b3/pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b", size = 64175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/40/b293a4fa769f3b02ab9e387c707c4cbdc34f073f945de0386107d4e669e6/pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", size = 63164 }, +] + +[[package]] +name = "pyinstrument" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b1/3cc18e0b39f3b043c8ceda8c0d52f36fffb8e28e8773ad75cccc4dee0955/pyinstrument-4.6.2.tar.gz", hash = "sha256:0002ee517ed8502bbda6eb2bb1ba8f95a55492fcdf03811ba13d4806e50dd7f6", size = 128412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/9e/87bea84420e87d7b0de9ceb6095844614b66943f91a716a2027a3dcd8367/pyinstrument-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7a1b1cd768ea7ea9ab6f5490f7e74431321bcc463e9441dbc2f769617252d9e2", size = 92612 }, + { url = "https://files.pythonhosted.org/packages/a1/4b/b79f0c2ad417ddd3eb0366e0ac91b5de14463508ad1e903583129c1e9efb/pyinstrument-4.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a386b9d09d167451fb2111eaf86aabf6e094fed42c15f62ec51d6980bce7d96", size = 86866 }, + { url = "https://files.pythonhosted.org/packages/ea/b5/8d1922f2110f78a18eb84933e804205630130f117f29cdc27b02f2eb96a8/pyinstrument-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23c3e3ca8553b9aac09bd978c73d21b9032c707ac6d803bae6a20ecc048df4a8", size = 106950 }, + { url = "https://files.pythonhosted.org/packages/12/d4/7dbd0cde0c4b7eee37486a7f90c516d16b03e9df216da57814a7f54a6fd6/pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f329f5534ca069420246f5ce57270d975229bcb92a3a3fd6b2ca086527d9764", size = 105911 }, + { url = "https://files.pythonhosted.org/packages/5b/b2/191b3590874b59055d7481500558b16a4828ab463a479d6c49db633abe82/pyinstrument-4.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dcdcc7ba224a0c5edfbd00b0f530f5aed2b26da5aaa2f9af5519d4aa8c7e41", size = 106867 }, + { url = "https://files.pythonhosted.org/packages/41/3a/50362f2cbfeeec3bf954fdf3964d4bc664cfe5acf668e2b4993c05442b8c/pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73db0c2c99119c65b075feee76e903b4ed82e59440fe8b5724acf5c7cb24721f", size = 109916 }, + { url = "https://files.pythonhosted.org/packages/63/40/f75f49d4c13b52089fde6d2c43eef76513a3849c7ad12ad279e639e5413a/pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:da58f265326f3cf3975366ccb8b39014f1e69ff8327958a089858d71c633d654", size = 109402 }, + { url = "https://files.pythonhosted.org/packages/31/fb/bbe9c5fd0b5548c8b5aae88b2d05a1d91f8115d9cd02388fd2197e284e64/pyinstrument-4.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:feebcf860f955401df30d029ec8de7a0c5515d24ea809736430fd1219686fe14", size = 110193 }, + { url = "https://files.pythonhosted.org/packages/78/e2/f3310f008aa294231bfa4e735678ba3a0804999d3da607a96d5cd6b3f49d/pyinstrument-4.6.2-cp310-cp310-win32.whl", hash = "sha256:b2b66ff0b16c8ecf1ec22de001cfff46872b2c163c62429055105564eef50b2e", size = 89291 }, + { url = "https://files.pythonhosted.org/packages/c6/44/2bf8b69efd07fade8d79ace764e19d3a38b5880fc3dd8dbf9c007c55095e/pyinstrument-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:8d104b7a7899d5fa4c5bf1ceb0c1a070615a72c5dc17bc321b612467ad5c5d88", size = 90055 }, + { url = "https://files.pythonhosted.org/packages/2b/e2/69cebd674cc61733141b51d97afe7c7dc90a37bbf2b7ea632bb4a500e888/pyinstrument-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:62f6014d2b928b181a52483e7c7b82f2c27e22c577417d1681153e5518f03317", size = 92159 }, + { url = "https://files.pythonhosted.org/packages/88/70/81931097221146d909b0dd8997af59825fb29bff8a0de17bc2e60b0b1322/pyinstrument-4.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5c8d763c5df55131670ba2a01a8aebd0d490a789904a55eb6a8b8d497f110", size = 86613 }, + { url = "https://files.pythonhosted.org/packages/55/c1/c67c96c8b9838c2720e36ddabeaf89e04de602571dd85c8bcab93bbd8067/pyinstrument-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ed4e8c6c84e0e6429ba7008a66e435ede2d8cb027794c20923c55669d9c5633", size = 105256 }, + { url = "https://files.pythonhosted.org/packages/a5/44/a15a267fa9bec6c775de6be8f6285e80f503515ab4a6a00ccc186be7fc0f/pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c0f0e1d8f8c70faa90ff57f78ac0dda774b52ea0bfb2d9f0f41ce6f3e7c869e", size = 103928 }, + { url = "https://files.pythonhosted.org/packages/fa/f1/a5834ab1e8fce5b293d9f8603491513488acbc15e008b61339afdf83169e/pyinstrument-4.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3c44cb037ad0d6e9d9a48c14d856254ada641fbd0ae9de40da045fc2226a2a", size = 104871 }, + { url = "https://files.pythonhosted.org/packages/f1/46/085a27fbe5c7c943c52b941dd15da951877dff3354d6a01275893d9566ba/pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:be9901f17ac2f527c352f2fdca3d717c1d7f2ce8a70bad5a490fc8cc5d2a6007", size = 109183 }, + { url = "https://files.pythonhosted.org/packages/cc/48/0d836ced594b9b7215a75be5c8c3c6395a63647bb6ab41fcf86b8288a047/pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a9791bf8916c1cf439c202fded32de93354b0f57328f303d71950b0027c7811", size = 108916 }, + { url = "https://files.pythonhosted.org/packages/5d/6d/1200f09197410bb48f68eef330618da21c6a870dd087d25b7d2e46528901/pyinstrument-4.6.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d6162615e783c59e36f2d7caf903a7e3ecb6b32d4a4ae8907f2760b2ef395bf6", size = 109237 }, + { url = "https://files.pythonhosted.org/packages/2d/e1/50f200fa4de18da60885161a7a79cfbdd43ea41685223a4f7cffb9751228/pyinstrument-4.6.2-cp311-cp311-win32.whl", hash = "sha256:28af084aa84bbfd3620ebe71d5f9a0deca4451267f363738ca824f733de55056", size = 89251 }, + { url = "https://files.pythonhosted.org/packages/40/92/4224f99c3a6c679f32a725fc168de6846d9dd533f1a112f75fc1412c64fa/pyinstrument-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:dd6007d3c2e318e09e582435dd8d111cccf30d342af66886b783208813caf3d7", size = 89887 }, + { url = "https://files.pythonhosted.org/packages/99/51/3fab7658a92766d27496d0ee204fa2e6334f33a23206933366d693383f3e/pyinstrument-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3a165e0d2deb212d4cf439383982a831682009e1b08733c568cac88c89784e62", size = 92610 }, + { url = "https://files.pythonhosted.org/packages/82/f8/6e1f05ef1beff9bd6f6d44ab8c1e527621cb322f1cf62a39827905106200/pyinstrument-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ba858b3d6f6e5597c641edcc0e7e464f85aba86d71bc3b3592cb89897bf43f6", size = 86865 }, + { url = "https://files.pythonhosted.org/packages/d9/5f/8cc342d69114511e0840fb70dc15869d1126d19e254fbc2b818f996f1e62/pyinstrument-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd8e547cf3df5f0ec6e4dffbe2e857f6b28eda51b71c3c0b5a2fc0646527835", size = 106642 }, + { url = "https://files.pythonhosted.org/packages/48/52/31d41af7d9a21cf4ea5fe299c573120edc795034b999be92b98d9c7645f5/pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de2c1714a37a820033b19cf134ead43299a02662f1379140974a9ab733c5f3a", size = 105612 }, + { url = "https://files.pythonhosted.org/packages/56/92/c3b88a4cf63e3ffb070cf2bd7b7aa0b68240391e15c063e106f4f434763a/pyinstrument-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01fc45dedceec3df81668d702bca6d400d956c8b8494abc206638c167c78dfd9", size = 106524 }, + { url = "https://files.pythonhosted.org/packages/74/f8/b689f89b5803cb8dbe056eacb4376f1636dc8a1a64d2c8f06e5354ec088d/pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5b6e161ef268d43ee6bbfae7fd2cdd0a52c099ddd21001c126ca1805dc906539", size = 109608 }, + { url = "https://files.pythonhosted.org/packages/40/e5/13859b3371a1c02dc9a32b37d543e3ee34d8322d2d89147d1f444d209c5e/pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6ba8e368d0421f15ba6366dfd60ec131c1b46505d021477e0f865d26cf35a605", size = 109060 }, + { url = "https://files.pythonhosted.org/packages/45/d5/5dda8597f7e22af4664854dab57f31a4fbff958cb69e8cecd165a0bcf3dd/pyinstrument-4.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edca46f04a573ac2fb11a84b937844e6a109f38f80f4b422222fb5be8ecad8cb", size = 109837 }, + { url = "https://files.pythonhosted.org/packages/76/50/80fc9f8a77a1c4bc0e605714d17dec5d65a276770b436560159fac2549bf/pyinstrument-4.6.2-cp39-cp39-win32.whl", hash = "sha256:baf375953b02fe94d00e716f060e60211ede73f49512b96687335f7071adb153", size = 89298 }, + { url = "https://files.pythonhosted.org/packages/f7/c1/2abbb1993084ce4cfd01a683ec6e288a70dd673bd78b4c5b341a8f76dd4d/pyinstrument-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:af1a953bce9fd530040895d01ff3de485e25e1576dccb014f76ba9131376fcad", size = 90053 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 }, +] + +[[package]] +name = "python-slugify" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/63/0f60208d0d3dde1a87d30a82906fa9b00e902b57f1ae9565d780de4b41d1/python-slugify-8.0.1.tar.gz", hash = "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27", size = 11297 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/85/6aa722a11307ec572682023b76cad4c52cda708dfc25fcb4b4a6051da7ab/python_slugify-8.0.1-py2.py3-none-any.whl", hash = "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395", size = 9710 }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574 }, +] + +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/52/531ef197b426646f26b53815a7d2a67cb7a331ef098bb276db26a68ac49f/requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a", size = 52027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892 }, +] + +[[package]] +name = "scikit-learn" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "scipy", version = "1.15.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/e5/c09d20723bfd91315f6f4ddc77912b0dcc09588b4ca7ad2ffa204607ad7f/scikit-learn-1.4.2.tar.gz", hash = "sha256:daa1c471d95bad080c6e44b4946c9390a4842adc3082572c20e4f8884e39e959", size = 7763055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2fe83526acf1448ac6d5d579c65324dd0ff769fdf74a1989072edcac4210/scikit_learn-1.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8539a41b3d6d1af82eb629f9c57f37428ff1481c1e34dddb3b9d7af8ede67ac5", size = 11567295 }, + { url = "https://files.pythonhosted.org/packages/7f/1c/047e16924f1e26ec8047d954613cffd174ef9cdc110c08c9bbcf9cdded4d/scikit_learn-1.4.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:68b8404841f944a4a1459b07198fa2edd41a82f189b44f3e1d55c104dbc2e40c", size = 10447678 }, + { url = "https://files.pythonhosted.org/packages/2c/8c/54b363fe83d30f79545f0e8bc1ff02b062d1efe738fee31e9c8db6dad40a/scikit_learn-1.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81bf5d8bbe87643103334032dd82f7419bc8c8d02a763643a6b9a5c7288c5054", size = 11506998 }, + { url = "https://files.pythonhosted.org/packages/8f/38/420ee614359d8f453ffe2bb5c2e963bf50459d9bbd3f5a92aa9059658955/scikit_learn-1.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36f0ea5d0f693cb247a073d21a4123bdf4172e470e6d163c12b74cbb1536cf38", size = 12140555 }, + { url = "https://files.pythonhosted.org/packages/54/43/40a4ae4b05b00cd532fe77fdd1629dd5355776d0977a7e3b8890bec309a9/scikit_learn-1.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:87440e2e188c87db80ea4023440923dccbd56fbc2d557b18ced00fef79da0727", size = 10597017 }, + { url = "https://files.pythonhosted.org/packages/59/11/63de36e6933b03490fdfe5cbc9b5a68870a1281d8e705a23b33076dc82fb/scikit_learn-1.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:45dee87ac5309bb82e3ea633955030df9bbcb8d2cdb30383c6cd483691c546cc", size = 11558461 }, + { url = "https://files.pythonhosted.org/packages/f2/30/1299e84d2ba3bc735baf17cebbf5b9d55144243c41b3ec6559ce3cf61e23/scikit_learn-1.4.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:1d0b25d9c651fd050555aadd57431b53d4cf664e749069da77f3d52c5ad14b3b", size = 10451621 }, + { url = "https://files.pythonhosted.org/packages/cc/6d/2b03edb51e688db0dc2958ab18edf71c8cc313172636cbdc0b1fc7670777/scikit_learn-1.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0203c368058ab92efc6168a1507d388d41469c873e96ec220ca8e74079bf62e", size = 11523470 }, + { url = "https://files.pythonhosted.org/packages/4e/53/14405a47292b59235d811a2af8634aba188ccfd1a38ef4b8042f3447d79a/scikit_learn-1.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44c62f2b124848a28fd695db5bc4da019287abf390bfce602ddc8aa1ec186aae", size = 12146964 }, + { url = "https://files.pythonhosted.org/packages/79/3d/02d5d3ed359498fec3abdf65407d3c07e3b8765af17464969055aaec5171/scikit_learn-1.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:5cd7b524115499b18b63f0c96f4224eb885564937a0b3477531b2b63ce331904", size = 10602955 }, + { url = "https://files.pythonhosted.org/packages/3b/cd/4e813830641f5f890cd337154f0e5184f685bd4ce2a6d602310b661e9666/scikit_learn-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9993d5e78a8148b1d0fdf5b15ed92452af5581734129998c26f481c46586d68", size = 11603188 }, + { url = "https://files.pythonhosted.org/packages/d9/ff/5ae7e924925499902104bf046ccd9b2b2e078171bcedc4ee2e2f2d2cacca/scikit_learn-1.4.2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:426d258fddac674fdf33f3cb2d54d26f49406e2599dbf9a32b4d1696091d4256", size = 10477064 }, + { url = "https://files.pythonhosted.org/packages/f3/fb/228e197a9d43c8c05b667e965fee347e888408c421aa011b273d179567ae/scikit_learn-1.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5460a1a5b043ae5ae4596b3126a4ec33ccba1b51e7ca2c5d36dac2169f62ab1d", size = 11560172 }, + { url = "https://files.pythonhosted.org/packages/82/40/5906b7ae6e303ece856a4262b0e7a963eb0187c167b539ff87bc1fe8f887/scikit_learn-1.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49d64ef6cb8c093d883e5a36c4766548d974898d378e395ba41a806d0e824db8", size = 12159991 }, + { url = "https://files.pythonhosted.org/packages/b1/ed/051ea344b38c8e0310c4eba02593d446e35656ed1328de7bd058e9223310/scikit_learn-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:c97a50b05c194be9146d61fe87dbf8eac62b203d9e87a3ccc6ae9aed2dfaf361", size = 10627708 }, +] + +[[package]] +name = "scipy" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/00/48c2f661e2816ccf2ecd77982f6605b2950afe60f60a52b4cbbc2504aa8f/scipy-1.13.1.tar.gz", hash = "sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c", size = 57210720 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/59/41b2529908c002ade869623b87eecff3e11e3ce62e996d0bdcb536984187/scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca", size = 39328076 }, + { url = "https://files.pythonhosted.org/packages/d5/33/f1307601f492f764062ce7dd471a14750f3360e33cd0f8c614dae208492c/scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f", size = 30306232 }, + { url = "https://files.pythonhosted.org/packages/c0/66/9cd4f501dd5ea03e4a4572ecd874936d0da296bd04d1c45ae1a4a75d9c3a/scipy-1.13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfa31f1def5c819b19ecc3a8b52d28ffdcc7ed52bb20c9a7589669dd3c250989", size = 33743202 }, + { url = "https://files.pythonhosted.org/packages/a3/ba/7255e5dc82a65adbe83771c72f384d99c43063648456796436c9a5585ec3/scipy-1.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26264b282b9da0952a024ae34710c2aff7d27480ee91a2e82b7b7073c24722f", size = 38577335 }, + { url = "https://files.pythonhosted.org/packages/49/a5/bb9ded8326e9f0cdfdc412eeda1054b914dfea952bda2097d174f8832cc0/scipy-1.13.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eccfa1906eacc02de42d70ef4aecea45415f5be17e72b61bafcfd329bdc52e94", size = 38820728 }, + { url = "https://files.pythonhosted.org/packages/12/30/df7a8fcc08f9b4a83f5f27cfaaa7d43f9a2d2ad0b6562cced433e5b04e31/scipy-1.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:2831f0dc9c5ea9edd6e51e6e769b655f08ec6db6e2e10f86ef39bd32eb11da54", size = 46210588 }, + { url = "https://files.pythonhosted.org/packages/b4/15/4a4bb1b15bbd2cd2786c4f46e76b871b28799b67891f23f455323a0cdcfb/scipy-1.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:27e52b09c0d3a1d5b63e1105f24177e544a222b43611aaf5bc44d4a0979e32f9", size = 39333805 }, + { url = "https://files.pythonhosted.org/packages/ba/92/42476de1af309c27710004f5cdebc27bec62c204db42e05b23a302cb0c9a/scipy-1.13.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:54f430b00f0133e2224c3ba42b805bfd0086fe488835effa33fa291561932326", size = 30317687 }, + { url = "https://files.pythonhosted.org/packages/80/ba/8be64fe225360a4beb6840f3cbee494c107c0887f33350d0a47d55400b01/scipy-1.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89369d27f9e7b0884ae559a3a956e77c02114cc60a6058b4e5011572eea9299", size = 33694638 }, + { url = "https://files.pythonhosted.org/packages/36/07/035d22ff9795129c5a847c64cb43c1fa9188826b59344fee28a3ab02e283/scipy-1.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a78b4b3345f1b6f68a763c6e25c0c9a23a9fd0f39f5f3d200efe8feda560a5fa", size = 38569931 }, + { url = "https://files.pythonhosted.org/packages/d9/10/f9b43de37e5ed91facc0cfff31d45ed0104f359e4f9a68416cbf4e790241/scipy-1.13.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45484bee6d65633752c490404513b9ef02475b4284c4cfab0ef946def50b3f59", size = 38838145 }, + { url = "https://files.pythonhosted.org/packages/4a/48/4513a1a5623a23e95f94abd675ed91cfb19989c58e9f6f7d03990f6caf3d/scipy-1.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:5713f62f781eebd8d597eb3f88b8bf9274e79eeabf63afb4a737abc6c84ad37b", size = 46196227 }, + { url = "https://files.pythonhosted.org/packages/7f/29/c2ea58c9731b9ecb30b6738113a95d147e83922986b34c685b8f6eefde21/scipy-1.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:436bbb42a94a8aeef855d755ce5a465479c721e9d684de76bf61a62e7c2b81d5", size = 39352927 }, + { url = "https://files.pythonhosted.org/packages/5c/c0/e71b94b20ccf9effb38d7147c0064c08c622309fd487b1b677771a97d18c/scipy-1.13.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8335549ebbca860c52bf3d02f80784e91a004b71b059e3eea9678ba994796a24", size = 30324538 }, + { url = "https://files.pythonhosted.org/packages/6d/0f/aaa55b06d474817cea311e7b10aab2ea1fd5d43bc6a2861ccc9caec9f418/scipy-1.13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d533654b7d221a6a97304ab63c41c96473ff04459e404b83275b60aa8f4b7004", size = 33732190 }, + { url = "https://files.pythonhosted.org/packages/35/f5/d0ad1a96f80962ba65e2ce1de6a1e59edecd1f0a7b55990ed208848012e0/scipy-1.13.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637e98dcf185ba7f8e663e122ebf908c4702420477ae52a04f9908707456ba4d", size = 38612244 }, + { url = "https://files.pythonhosted.org/packages/8d/02/1165905f14962174e6569076bcc3315809ae1291ed14de6448cc151eedfd/scipy-1.13.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a014c2b3697bde71724244f63de2476925596c24285c7a637364761f8710891c", size = 38845637 }, + { url = "https://files.pythonhosted.org/packages/3e/77/dab54fe647a08ee4253963bcd8f9cf17509c8ca64d6335141422fe2e2114/scipy-1.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:392e4ec766654852c25ebad4f64e4e584cf19820b980bc04960bca0b0cd6eaa2", size = 46227440 }, +] + +[[package]] +name = "scipy" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/df/ef233fff6838fe6f7840d69b5ef9f20d2b5c912a8727b21ebf876cb15d54/scipy-1.15.2-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a2ec871edaa863e8213ea5df811cd600734f6400b4af272e1c011e69401218e9", size = 38692502 }, + { url = "https://files.pythonhosted.org/packages/5c/20/acdd4efb8a68b842968f7bc5611b1aeb819794508771ad104de418701422/scipy-1.15.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:6f223753c6ea76983af380787611ae1291e3ceb23917393079dcc746ba60cfb5", size = 30085508 }, + { url = "https://files.pythonhosted.org/packages/42/55/39cf96ca7126f1e78ee72a6344ebdc6702fc47d037319ad93221063e6cf4/scipy-1.15.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ecf797d2d798cf7c838c6d98321061eb3e72a74710e6c40540f0e8087e3b499e", size = 22359166 }, + { url = "https://files.pythonhosted.org/packages/51/48/708d26a4ab8a1441536bf2dfcad1df0ca14a69f010fba3ccbdfc02df7185/scipy-1.15.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:9b18aa747da280664642997e65aab1dd19d0c3d17068a04b3fe34e2559196cb9", size = 25112047 }, + { url = "https://files.pythonhosted.org/packages/dd/65/f9c5755b995ad892020381b8ae11f16d18616208e388621dfacc11df6de6/scipy-1.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87994da02e73549dfecaed9e09a4f9d58a045a053865679aeb8d6d43747d4df3", size = 35536214 }, + { url = "https://files.pythonhosted.org/packages/de/3c/c96d904b9892beec978562f64d8cc43f9cca0842e65bd3cd1b7f7389b0ba/scipy-1.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69ea6e56d00977f355c0f84eba69877b6df084516c602d93a33812aa04d90a3d", size = 37646981 }, + { url = "https://files.pythonhosted.org/packages/3d/74/c2d8a24d18acdeae69ed02e132b9bc1bb67b7bee90feee1afe05a68f9d67/scipy-1.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:888307125ea0c4466287191e5606a2c910963405ce9671448ff9c81c53f85f58", size = 37230048 }, + { url = "https://files.pythonhosted.org/packages/42/19/0aa4ce80eca82d487987eff0bc754f014dec10d20de2f66754fa4ea70204/scipy-1.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9412f5e408b397ff5641080ed1e798623dbe1ec0d78e72c9eca8992976fa65aa", size = 40010322 }, + { url = "https://files.pythonhosted.org/packages/d0/d2/f0683b7e992be44d1475cc144d1f1eeae63c73a14f862974b4db64af635e/scipy-1.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:b5e025e903b4f166ea03b109bb241355b9c42c279ea694d8864d033727205e65", size = 41233385 }, + { url = "https://files.pythonhosted.org/packages/40/1f/bf0a5f338bda7c35c08b4ed0df797e7bafe8a78a97275e9f439aceb46193/scipy-1.15.2-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:92233b2df6938147be6fa8824b8136f29a18f016ecde986666be5f4d686a91a4", size = 38703651 }, + { url = "https://files.pythonhosted.org/packages/de/54/db126aad3874601048c2c20ae3d8a433dbfd7ba8381551e6f62606d9bd8e/scipy-1.15.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:62ca1ff3eb513e09ed17a5736929429189adf16d2d740f44e53270cc800ecff1", size = 30102038 }, + { url = "https://files.pythonhosted.org/packages/61/d8/84da3fffefb6c7d5a16968fe5b9f24c98606b165bb801bb0b8bc3985200f/scipy-1.15.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4c6676490ad76d1c2894d77f976144b41bd1a4052107902238047fb6a473e971", size = 22375518 }, + { url = "https://files.pythonhosted.org/packages/44/78/25535a6e63d3b9c4c90147371aedb5d04c72f3aee3a34451f2dc27c0c07f/scipy-1.15.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8bf5cb4a25046ac61d38f8d3c3426ec11ebc350246a4642f2f315fe95bda655", size = 25142523 }, + { url = "https://files.pythonhosted.org/packages/e0/22/4b4a26fe1cd9ed0bc2b2cb87b17d57e32ab72c346949eaf9288001f8aa8e/scipy-1.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a8e34cf4c188b6dd004654f88586d78f95639e48a25dfae9c5e34a6dc34547e", size = 35491547 }, + { url = "https://files.pythonhosted.org/packages/32/ea/564bacc26b676c06a00266a3f25fdfe91a9d9a2532ccea7ce6dd394541bc/scipy-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0d2c2075946346e4408b211240764759e0fabaeb08d871639b5f3b1aca8a0", size = 37634077 }, + { url = "https://files.pythonhosted.org/packages/43/c2/bfd4e60668897a303b0ffb7191e965a5da4056f0d98acfb6ba529678f0fb/scipy-1.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:42dabaaa798e987c425ed76062794e93a243be8f0f20fff6e7a89f4d61cb3d40", size = 37231657 }, + { url = "https://files.pythonhosted.org/packages/4a/75/5f13050bf4f84c931bcab4f4e83c212a36876c3c2244475db34e4b5fe1a6/scipy-1.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f5e296ec63c5da6ba6fa0343ea73fd51b8b3e1a300b0a8cae3ed4b1122c7462", size = 40035857 }, + { url = "https://files.pythonhosted.org/packages/b9/8b/7ec1832b09dbc88f3db411f8cdd47db04505c4b72c99b11c920a8f0479c3/scipy-1.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:597a0c7008b21c035831c39927406c6181bcf8f60a73f36219b69d010aa04737", size = 41217654 }, +] + +[[package]] +name = "sentry-sdk" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/73/fbd4b6ce03acd4c06d553a528e6835b37da857cd9353c3f2178a6fbd2459/sentry-sdk-1.26.0.tar.gz", hash = "sha256:760e4fb6d01c994110507133e08ecd4bdf4d75ee4be77f296a3579796cf73134", size = 187356 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/b4/a5f11172cd441d9dbb1c0f12f61816b08bba473eba45dd0d2e00ad018fe5/sentry_sdk-1.26.0-py2.py3-none-any.whl", hash = "sha256:0c9f858337ec3781cf4851972ef42bba8c9828aea116b0dbed8f38c5f9a1896c", size = 209416 }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "setuptools" +version = "80.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/dc/3976b322de9d2e87ed0007cf04cc7553969b6c7b3f48a565d0333748fbcd/setuptools-80.3.1.tar.gz", hash = "sha256:31e2c58dbb67c99c289f51c16d899afedae292b978f8051efaf6262d8212f927", size = 1315082 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7e/5d8af3317ddbf9519b687bd1c39d8737fde07d97f54df65553faca5cffb1/setuptools-80.3.1-py3-none-any.whl", hash = "sha256:ea8e00d7992054c4c592aeb892f6ad51fe1b4d90cc6947cc45c45717c40ec537", size = 1201172 }, +] + +[[package]] +name = "shapely" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/a7/de139da3ce303101c357a9ba801328cba85cf6ace157da31a4007bca85e4/shapely-2.0.1.tar.gz", hash = "sha256:66a6b1a3e72ece97fc85536a281476f9b7794de2e646ca8a4517e2e3c1446893", size = 275535 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/da/00737e3cd038d489c257a00829c27b3ff2d3ec264c78540a5d168a06922f/shapely-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b06d031bc64149e340448fea25eee01360a58936c89985cf584134171e05863f", size = 2407700 }, + { url = "https://files.pythonhosted.org/packages/1f/2a/dc3353c2431cf53e8d04bb8fba27e584410ca3435c9c85f76d71bf0c0e80/shapely-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9a6ac34c16f4d5d3c174c76c9d7614ec8fe735f8f82b6cc97a46b54f386a86bf", size = 1363779 }, + { url = "https://files.pythonhosted.org/packages/ec/41/d59208743e737184e1b403e95a937aebb022b8459e99efbcd5208fc8be46/shapely-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:865bc3d7cc0ea63189d11a0b1120d1307ed7a64720a8bfa5be2fde5fc6d0d33f", size = 1199471 }, + { url = "https://files.pythonhosted.org/packages/69/2e/29633eca429c9e7eca1264df1764a7f179ec9ec186ba25d874342dcb1a47/shapely-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b4833235b90bc87ee26c6537438fa77559d994d2d3be5190dd2e54d31b2820", size = 2222156 }, + { url = "https://files.pythonhosted.org/packages/a8/a5/403728b5614b28083f6424dfdefec5fcf58068495fb03bb08532671c642f/shapely-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce88ec79df55430e37178a191ad8df45cae90b0f6972d46d867bf6ebbb58cc4d", size = 2304792 }, + { url = "https://files.pythonhosted.org/packages/cf/0f/c0456b1382d2a6815727cbd9c0713deca11653b330ba14b2cc165f0b9565/shapely-2.0.1-cp310-cp310-win32.whl", hash = "sha256:01224899ff692a62929ef1a3f5fe389043e262698a708ab7569f43a99a48ae82", size = 1224290 }, + { url = "https://files.pythonhosted.org/packages/81/8a/7ac076a86b2632f1872284c5e60ed5f2fc26094875a85b35d9fa17b52504/shapely-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:da71de5bf552d83dcc21b78cc0020e86f8d0feea43e202110973987ffa781c21", size = 1366228 }, + { url = "https://files.pythonhosted.org/packages/8a/31/ad4881727b700a9320a4139ac3483972901a9fdd17bb6a599aca810dbd85/shapely-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:502e0a607f1dcc6dee0125aeee886379be5242c854500ea5fd2e7ac076b9ce6d", size = 2398678 }, + { url = "https://files.pythonhosted.org/packages/7b/e3/92ec80d72de8b881c52e47db1fd2f0519d49b6ad65c4c2a3fcbb9f88a91f/shapely-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7d3bbeefd8a6a1a1017265d2d36f8ff2d79d0162d8c141aa0d37a87063525656", size = 1358751 }, + { url = "https://files.pythonhosted.org/packages/18/a6/2e1761f21605e3562b223be7ad82f2edb5c9babdaa8b2d90979112be70f3/shapely-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f470a130d6ddb05b810fc1776d918659407f8d025b7f56d2742a596b6dffa6c7", size = 1195469 }, + { url = "https://files.pythonhosted.org/packages/70/21/39c2afae46154f824564d6b5b7213a84d083e243b42b5a1e76de481f91bb/shapely-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4641325e065fd3e07d55677849c9ddfd0cf3ee98f96475126942e746d55b17c8", size = 2254608 }, + { url = "https://files.pythonhosted.org/packages/e9/7c/76e54fa615a20ceb876e4de6b9f01a56926184bcc2076186c51c27ce39ad/shapely-2.0.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90cfa4144ff189a3c3de62e2f3669283c98fb760cfa2e82ff70df40f11cadb39", size = 2521453 }, + { url = "https://files.pythonhosted.org/packages/04/67/05e96af1c4ee130e12ac228da1ab86f7581809d8f008aa3a9ec19ea22eb2/shapely-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70a18fc7d6418e5aea76ac55dce33f98e75bd413c6eb39cfed6a1ba36469d7d4", size = 2336558 }, + { url = "https://files.pythonhosted.org/packages/bc/f1/c9db479170a7288d6bd2adcd1892785a3206b7a6f7972e7478714cb39e3c/shapely-2.0.1-cp311-cp311-win32.whl", hash = "sha256:09d6c7763b1bee0d0a2b84bb32a4c25c6359ad1ac582a62d8b211e89de986154", size = 1222645 }, + { url = "https://files.pythonhosted.org/packages/28/81/0239107ccd32eb30085c99fbf22da686aa72278afcc2c8fdf06fa0fa6320/shapely-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d8f55f355be7821dade839df785a49dc9f16d1af363134d07eb11e9207e0b189", size = 1364032 }, + { url = "https://files.pythonhosted.org/packages/b0/b4/b0cedcc974f5d3fba51850f81961f48a1246b4c4ddf4cd3faef6829e4173/shapely-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac1dfc397475d1de485e76de0c3c91cc9d79bd39012a84bb0f5e8a199fc17bef", size = 2407571 }, + { url = "https://files.pythonhosted.org/packages/36/a4/7e542a209f862f967d7cb8e939eff155f4294a27d17e16441fb8bdd51a2c/shapely-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33403b8896e1d98aaa3a52110d828b18985d740cc9f34f198922018b1e0f8afe", size = 1363781 }, + { url = "https://files.pythonhosted.org/packages/ea/aa/45fbd031edf3149cb767d8b9f9db45d5faf0324d743c6b8fb0298cc022d0/shapely-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2569a4b91caeef54dd5ae9091ae6f63526d8ca0b376b5bb9fd1a3195d047d7d4", size = 1199188 }, + { url = "https://files.pythonhosted.org/packages/98/e4/2d5b48e13cbcc976f468b995bb8bcfa8e758a8b73fe307af54184989158e/shapely-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a70a614791ff65f5e283feed747e1cc3d9e6c6ba91556e640636bbb0a1e32a71", size = 2229950 }, + { url = "https://files.pythonhosted.org/packages/0e/da/055d5b854a9a702fed0965d37754b79967ecfd67fe8377264e6a00b521ea/shapely-2.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c43755d2c46b75a7b74ac6226d2cc9fa2a76c3263c5ae70c195c6fb4e7b08e79", size = 2499272 }, + { url = "https://files.pythonhosted.org/packages/2d/f2/8ec281d357e8bb7d08dc8d727f0e4c8ef3dae7d3fa75c69c8e452bb82d50/shapely-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad81f292fffbd568ae71828e6c387da7eb5384a79db9b4fde14dd9fdeffca9a", size = 2311258 }, + { url = "https://files.pythonhosted.org/packages/41/63/088ea482b1f2a046ec0576b173125a6485d0cc7e3868d50e74f4a89039f5/shapely-2.0.1-cp39-cp39-win32.whl", hash = "sha256:b50c401b64883e61556a90b89948297f1714dbac29243d17ed9284a47e6dd731", size = 1226458 }, + { url = "https://files.pythonhosted.org/packages/a7/ae/eab645c4075093584b7a65ab71cb8ff4563a015bd935c9b55dba14b2c1eb/shapely-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bca57b683e3d94d0919e2f31e4d70fdfbb7059650ef1b431d9f4e045690edcd5", size = 1368621 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/07/a928d473438adb98ebd2264f584c4bd2dd711dfe6caf4b1906cba14dd375/SQLAlchemy-2.0.19.tar.gz", hash = "sha256:77a14fa20264af73ddcdb1e2b9c5a829b8cc6b8304d0f093271980e36c200a3f", size = 9425046 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/42/101761a65b8d83efa5d87cbb61144dae557ed60087daeae89e965449963f/SQLAlchemy-2.0.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9deaae357edc2091a9ed5d25e9ee8bba98bcfae454b3911adeaf159c2e9ca9e3", size = 1994760 }, + { url = "https://files.pythonhosted.org/packages/56/f9/55ced64ceaceb40d3df6c0ba38094bd10f47e31b354f4e7bc90503b17c99/SQLAlchemy-2.0.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bf0fd65b50a330261ec7fe3d091dfc1c577483c96a9fa1e4323e932961aa1b5", size = 1985032 }, + { url = "https://files.pythonhosted.org/packages/f8/b7/f915426cfa06d0a63d425817d5c000ee9b7a8c275af0748ea8b31f6f8118/SQLAlchemy-2.0.19-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d90ccc15ba1baa345796a8fb1965223ca7ded2d235ccbef80a47b85cea2d71a", size = 2717248 }, + { url = "https://files.pythonhosted.org/packages/3a/78/6bb3889e893e4605107a016e63636f278223b0526cb7927323c244ff877c/SQLAlchemy-2.0.19-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4e688f6784427e5f9479d1a13617f573de8f7d4aa713ba82813bcd16e259d1", size = 2721702 }, + { url = "https://files.pythonhosted.org/packages/72/f1/5c78b44412d67080c88918477eecba404d2951bacbc8ac527fe8261d4c0e/SQLAlchemy-2.0.19-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:584f66e5e1979a7a00f4935015840be627e31ca29ad13f49a6e51e97a3fb8cae", size = 2736149 }, + { url = "https://files.pythonhosted.org/packages/72/d3/a1b67abe359791e19e7a077643be3a741f5125980c219c4130f81f41fb73/SQLAlchemy-2.0.19-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c69ce70047b801d2aba3e5ff3cba32014558966109fecab0c39d16c18510f15", size = 2740187 }, + { url = "https://files.pythonhosted.org/packages/9c/cd/82d46a42e589a51fcb5be97db3e36b64cbc7002d9e27872146853cce18d6/SQLAlchemy-2.0.19-cp310-cp310-win32.whl", hash = "sha256:96f0463573469579d32ad0c91929548d78314ef95c210a8115346271beeeaaa2", size = 1973487 }, + { url = "https://files.pythonhosted.org/packages/71/41/eb74f55dfe9305736430051fc2a77d0479ff80d46aabd832dd583d242ad8/SQLAlchemy-2.0.19-cp310-cp310-win_amd64.whl", hash = "sha256:22bafb1da60c24514c141a7ff852b52f9f573fb933b1e6b5263f0daa28ce6db9", size = 1990026 }, + { url = "https://files.pythonhosted.org/packages/09/12/1fab1eba1377cfb386a7706585c48f28feee1557ec78669e284341a2ca37/SQLAlchemy-2.0.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6894708eeb81f6d8193e996257223b6bb4041cb05a17cd5cf373ed836ef87a2", size = 1992972 }, + { url = "https://files.pythonhosted.org/packages/c4/fc/323560a8dfddbe321550dd05522ddde853c703a76669ed39a693c0e4e3af/SQLAlchemy-2.0.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8f2afd1aafded7362b397581772c670f20ea84d0a780b93a1a1529da7c3d369", size = 1982464 }, + { url = "https://files.pythonhosted.org/packages/d1/ed/941cdd6e082b451879b40658f3d493c6fa377895279a9681cb94997d01a6/SQLAlchemy-2.0.19-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15afbf5aa76f2241184c1d3b61af1a72ba31ce4161013d7cb5c4c2fca04fd6e", size = 2777738 }, + { url = "https://files.pythonhosted.org/packages/0e/cc/712e8bd9df620f2c1e40165449ce0bd9c4f4daecbabc0d0f9d92c6941aa8/SQLAlchemy-2.0.19-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc05b59142445a4efb9c1fd75c334b431d35c304b0e33f4fa0ff1ea4890f92e", size = 2780395 }, + { url = "https://files.pythonhosted.org/packages/00/1f/b62c150bf1960043150b1edf2d0f5b7fab27868da78fd3a8f5abe47e2f2b/SQLAlchemy-2.0.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5831138f0cc06b43edf5f99541c64adf0ab0d41f9a4471fd63b54ae18399e4de", size = 2780174 }, + { url = "https://files.pythonhosted.org/packages/8a/df/b773d790b12808176b1c0ec272bbac2641ee30c1b3e7cf0de4cc6e5e20df/SQLAlchemy-2.0.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3afa8a21a9046917b3a12ffe016ba7ebe7a55a6fc0c7d950beb303c735c3c3ad", size = 2782131 }, + { url = "https://files.pythonhosted.org/packages/4b/04/14b59b9bc0e095c809b317487b7d9d2621605e91bebe2b0e96cc4aafda26/SQLAlchemy-2.0.19-cp311-cp311-win32.whl", hash = "sha256:c896d4e6ab2eba2afa1d56be3d0b936c56d4666e789bfc59d6ae76e9fcf46145", size = 1972623 }, + { url = "https://files.pythonhosted.org/packages/2d/eb/46ea0ba0db3a84b5971cc258f13ee4ac2b12ed8198823f8ba7a03ab00ead/SQLAlchemy-2.0.19-cp311-cp311-win_amd64.whl", hash = "sha256:024d2f67fb3ec697555e48caeb7147cfe2c08065a4f1a52d93c3d44fc8e6ad1c", size = 1987603 }, + { url = "https://files.pythonhosted.org/packages/74/c2/a7571bb237d5294d5ceec60668072d75515f941b99675d9eb5e71ebb16ea/SQLAlchemy-2.0.19-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a752b7a9aceb0ba173955d4f780c64ee15a1a991f1c52d307d6215c6c73b3a4c", size = 1997863 }, + { url = "https://files.pythonhosted.org/packages/18/68/30bc1b948d3eeeb7a0595b3bdcd0bc56d31cb2508da79ebe57f6b4279bfb/SQLAlchemy-2.0.19-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7351c05db355da112e056a7b731253cbeffab9dfdb3be1e895368513c7d70106", size = 1987029 }, + { url = "https://files.pythonhosted.org/packages/84/98/fff200658b0685a44179b171a8072843b2d7ec8b481f37228fcb73ead996/SQLAlchemy-2.0.19-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa51ce4aea583b0c6b426f4b0563d3535c1c75986c4373a0987d84d22376585b", size = 2766260 }, + { url = "https://files.pythonhosted.org/packages/84/bc/72e7fce7151e2540b72776b515f10bee72d68112965b90b4cf400d39b6f1/SQLAlchemy-2.0.19-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae7473a67cd82a41decfea58c0eac581209a0aa30f8bc9190926fbf628bb17f7", size = 2762029 }, + { url = "https://files.pythonhosted.org/packages/28/8c/a00058708fcca01db498a63dede1a670efa200d76ce0a6a974fbfd44df94/SQLAlchemy-2.0.19-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:851a37898a8a39783aab603c7348eb5b20d83c76a14766a43f56e6ad422d1ec8", size = 2780792 }, + { url = "https://files.pythonhosted.org/packages/b3/27/8b226369b3fcd617cf86ff2190ac21cb0b5faf919c943d2042484654bb21/SQLAlchemy-2.0.19-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539010665c90e60c4a1650afe4ab49ca100c74e6aef882466f1de6471d414be7", size = 2775165 }, + { url = "https://files.pythonhosted.org/packages/af/29/bde11f6f2694a9d698321e4289a156bec2a5ed07d8878b2a65d3adf901f2/SQLAlchemy-2.0.19-cp39-cp39-win32.whl", hash = "sha256:f82c310ddf97b04e1392c33cf9a70909e0ae10a7e2ddc1d64495e3abdc5d19fb", size = 1976843 }, + { url = "https://files.pythonhosted.org/packages/de/c0/f48cd2079bc45771659071190c41f44acc99ed3cc48a887f0afdf872a5fb/SQLAlchemy-2.0.19-cp39-cp39-win_amd64.whl", hash = "sha256:8e712cfd2e07b801bc6b60fdf64853bc2bd0af33ca8fa46166a23fe11ce0dbb0", size = 1994581 }, + { url = "https://files.pythonhosted.org/packages/fd/01/723aae6192e3ac65338da311ea0bfe860ed243a951a96d8a936f3c3c7383/SQLAlchemy-2.0.19-py3-none-any.whl", hash = "sha256:314145c1389b021a9ad5aa3a18bac6f5d939f9087d7fc5443be28cba19d2c972", size = 1838576 }, +] + +[[package]] +name = "starlette" +version = "0.32.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/a9/a42ef0fb5479e21738a03c0aa1078023a38c23198b049cb901eeda06cde0/starlette-0.32.0.post1.tar.gz", hash = "sha256:e54e2b7e2fb06dff9eac40133583f10dfa05913f5a85bf26f427c7a40a9a3d02", size = 2837480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/6bfa6be40034fa04cc50e2a81d24a4e5b89279c688b51380d70ac31c0556/starlette-0.32.0.post1-py3-none-any.whl", hash = "sha256:cd0cb10ddb49313f609cedfac62c8c12e56c7314b66d89bb077ba228bada1b09", size = 70017 }, +] + +[[package]] +name = "taskingmanager" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiocache" }, + { name = "alembic" }, + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "bleach" }, + { name = "cachetools" }, + { name = "databases" }, + { name = "fastapi" }, + { name = "fastapi-mail" }, + { name = "geoalchemy2" }, + { name = "geojson" }, + { name = "gevent" }, + { name = "greenlet" }, + { name = "gunicorn", extra = ["gevent"] }, + { name = "httptools" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "itsdangerous" }, + { name = "loguru" }, + { name = "markdown" }, + { name = "newrelic" }, + { name = "numpy" }, + { name = "oauthlib" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyinstrument" }, + { name = "pytest" }, + { name = "python-dateutil" }, + { name = "python-dotenv" }, + { name = "python-slugify" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "scikit-learn" }, + { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "shapely" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "uvloop" }, + { name = "werkzeug" }, +] + +[package.dev-dependencies] +dev = [ + { name = "debugpy" }, + { name = "pyinstrument" }, +] +lint = [ + { name = "black" }, + { name = "flake8" }, +] +test = [ + { name = "coverage" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiocache", specifier = ">=0.12.3" }, + { name = "alembic", specifier = "==1.11.1" }, + { name = "apscheduler", specifier = "==3.10.1" }, + { name = "asyncpg", specifier = "==0.29.0" }, + { name = "bleach", specifier = "==6.0.0" }, + { name = "cachetools", specifier = "==5.3.1" }, + { name = "databases", specifier = ">=0.9.0" }, + { name = "fastapi", specifier = "==0.108.0" }, + { name = "fastapi-mail", specifier = "==1.4.1" }, + { name = "geoalchemy2", specifier = "==0.14.3" }, + { name = "geojson", specifier = "==3.1.0" }, + { name = "gevent", specifier = "==23.9.0" }, + { name = "greenlet", specifier = "==2.0.2" }, + { name = "gunicorn", extras = ["gevent"], specifier = "==20.1.0" }, + { name = "httptools", specifier = ">=0.6.4" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "importlib-metadata", specifier = "==6.8.0" }, + { name = "itsdangerous", specifier = "==2.1.2" }, + { name = "loguru", specifier = "==0.7.2" }, + { name = "markdown", specifier = "==3.4.4" }, + { name = "newrelic", specifier = "==8.8.0" }, + { name = "numpy", specifier = "==1.26.4" }, + { name = "oauthlib", specifier = "==3.2.2" }, + { name = "pandas", specifier = "==2.2.2" }, + { name = "pydantic", specifier = "==2.5.3" }, + { name = "pydantic-settings", specifier = "==2.1.0" }, + { name = "pyinstrument", specifier = "==4.6.2" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "python-dateutil", specifier = "==2.8.2" }, + { name = "python-dotenv", specifier = "==1.0.0" }, + { name = "python-slugify", specifier = "==8.0.1" }, + { name = "requests", specifier = "==2.31.0" }, + { name = "requests-oauthlib", specifier = "==1.3.1" }, + { name = "scikit-learn", specifier = "==1.4.2" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==1.26.0" }, + { name = "shapely", specifier = "==2.0.1" }, + { name = "sqlalchemy", specifier = "==2.0.19" }, + { name = "typing-extensions", specifier = "==4.8.0" }, + { name = "uvicorn", specifier = "==0.19.0" }, + { name = "uvloop", specifier = ">=0.21.0" }, + { name = "werkzeug", specifier = "==2.3.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "debugpy", specifier = "==1.8.1" }, + { name = "pyinstrument", specifier = ">=4.6.2" }, +] +lint = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "flake8", specifier = ">=7.2.0" }, +] +test = [ + { name = "coverage", specifier = ">=7.8.0" }, + { name = "pytest", specifier = ">=8.3.5" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/7a/8b94bb016069caa12fc9f587b28080ac33b4fbb8ca369b98bc0a4828543e/typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef", size = 71456 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/21/7d397a4b7934ff4028987914ac1044d3b7d52712f30e2ac7a2ae5bc86dd0/typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", size = 31584 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] + +[[package]] +name = "uvicorn" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/dd/e7d5d8a7018db6ec652c3412b1d5e328c8fbb0fe96947438937ac7dbe0b1/uvicorn-0.19.0.tar.gz", hash = "sha256:cf538f3018536edb1f4a826311137ab4944ed741d52aeb98846f52215de57f25", size = 36573 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/3f/e3a8b7d42f058e0d50e76b8d41cc18a5d1f989feb17c882e1c61e7403f52/uvicorn-0.19.0-py3-none-any.whl", hash = "sha256:cc277f7e73435748e69e075a721841f7c4a95dba06d12a72fe9874acced16f6f", size = 56637 }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019 }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898 }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735 }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126 }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789 }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523 }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410 }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476 }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855 }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185 }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256 }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323 }, + { url = "https://files.pythonhosted.org/packages/3c/a4/646a9d0edff7cde25fc1734695d3dfcee0501140dd0e723e4df3f0a50acb/uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b", size = 1439646 }, + { url = "https://files.pythonhosted.org/packages/01/2e/e128c66106af9728f86ebfeeb52af27ecd3cb09336f3e2f3e06053707a15/uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2", size = 800931 }, + { url = "https://files.pythonhosted.org/packages/2d/1a/9fbc2b1543d0df11f7aed1632f64bdf5ecc4053cf98cdc9edb91a65494f9/uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0", size = 3829660 }, + { url = "https://files.pythonhosted.org/packages/b8/c0/392e235e4100ae3b95b5c6dac77f82b529d2760942b1e7e0981e5d8e895d/uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75", size = 3827185 }, + { url = "https://files.pythonhosted.org/packages/e1/24/a5da6aba58f99aed5255eca87d58d1760853e8302d390820cc29058408e3/uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd", size = 3705833 }, + { url = "https://files.pythonhosted.org/packages/1a/5c/6ba221bb60f1e6474474102e17e38612ec7a06dc320e22b687ab563d877f/uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff", size = 3804696 }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + +[[package]] +name = "werkzeug" +version = "2.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/7e/c35cea5749237d40effc50ed1a1c7518d9f2e768fcf30b4e9ea119e74975/Werkzeug-2.3.6.tar.gz", hash = "sha256:98c774df2f91b05550078891dee5f0eb0cb797a522c757a2452b9cee5b202330", size = 833282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/d6/8040faecaba2feb84e1647af174b3243c9b90c163c7ea407820839931efe/Werkzeug-2.3.6-py3-none-any.whl", hash = "sha256:935539fa1413afbb9195b24880778422ed620c0fc09670945185cce4d91a8890", size = 242499 }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +] + +[[package]] +name = "zope-event" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824 }, +] + +[[package]] +name = "zope-interface" +version = "7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/71/e6177f390e8daa7e75378505c5ab974e0bf59c1d3b19155638c7afbf4b2d/zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", size = 208243 }, + { url = "https://files.pythonhosted.org/packages/52/db/7e5f4226bef540f6d55acfd95cd105782bc6ee044d9b5587ce2c95558a5e/zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", size = 208759 }, + { url = "https://files.pythonhosted.org/packages/28/ea/fdd9813c1eafd333ad92464d57a4e3a82b37ae57c19497bcffa42df673e4/zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", size = 254922 }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0000a4d497ef9fbf4f66bb6828b8d0a235e690d57c333be877bec763722f/zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", size = 249367 }, + { url = "https://files.pythonhosted.org/packages/3e/e5/0b359e99084f033d413419eff23ee9c2bd33bca2ca9f4e83d11856f22d10/zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", size = 254488 }, + { url = "https://files.pythonhosted.org/packages/7b/90/12d50b95f40e3b2fc0ba7f7782104093b9fd62806b13b98ef4e580f2ca61/zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", size = 211947 }, + { url = "https://files.pythonhosted.org/packages/98/7d/2e8daf0abea7798d16a58f2f3a2bf7588872eee54ac119f99393fdd47b65/zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", size = 208776 }, + { url = "https://files.pythonhosted.org/packages/a0/2a/0c03c7170fe61d0d371e4c7ea5b62b8cb79b095b3d630ca16719bf8b7b18/zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", size = 209296 }, + { url = "https://files.pythonhosted.org/packages/49/b4/451f19448772b4a1159519033a5f72672221e623b0a1bd2b896b653943d8/zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", size = 260997 }, + { url = "https://files.pythonhosted.org/packages/65/94/5aa4461c10718062c8f8711161faf3249d6d3679c24a0b81dd6fc8ba1dd3/zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", size = 255038 }, + { url = "https://files.pythonhosted.org/packages/9f/aa/1a28c02815fe1ca282b54f6705b9ddba20328fabdc37b8cf73fc06b172f0/zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", size = 259806 }, + { url = "https://files.pythonhosted.org/packages/a7/2c/82028f121d27c7e68632347fe04f4a6e0466e77bb36e104c8b074f3d7d7b/zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", size = 212305 }, + { url = "https://files.pythonhosted.org/packages/8c/2c/1f49dc8b4843c4f0848d8e43191aed312bad946a1563d1bf9e46cf2816ee/zope.interface-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb", size = 208349 }, + { url = "https://files.pythonhosted.org/packages/ed/7d/83ddbfc8424c69579a90fc8edc2b797223da2a8083a94d8dfa0e374c5ed4/zope.interface-7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7", size = 208799 }, + { url = "https://files.pythonhosted.org/packages/36/22/b1abd91854c1be03f5542fe092e6a745096d2eca7704d69432e119100583/zope.interface-7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137", size = 254267 }, + { url = "https://files.pythonhosted.org/packages/2a/dd/fcd313ee216ad0739ae00e6126bc22a0af62a74f76a9ca668d16cd276222/zope.interface-7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519", size = 248614 }, + { url = "https://files.pythonhosted.org/packages/88/d4/4ba1569b856870527cec4bf22b91fe704b81a3c1a451b2ccf234e9e0666f/zope.interface-7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75", size = 253800 }, + { url = "https://files.pythonhosted.org/packages/69/da/c9cfb384c18bd3a26d9fc6a9b5f32ccea49ae09444f097eaa5ca9814aff9/zope.interface-7.2-cp39-cp39-win_amd64.whl", hash = "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d", size = 211980 }, +]