diff --git a/.eslintrc.js b/.eslintrc.js index 20119f95da96..152821a7d190 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,7 @@ module.exports = { '.eslintrc.js', 'lint-staged.config.js', ], - plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'eslint-plugin-header', 'import'], + plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'import'], extends: [ 'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings', diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 31ed1f269d65..cffd4d2f590a 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -17,7 +17,7 @@ jobs: # If different modules use different Black configs, # we need to run Black for each python component group separately. # Otherwise, they all will use the same config. - ENABLED_DIRS=("cvat-sdk" "cvat-cli" "tests/python/sdk" "tests/python/cli") + ENABLED_DIRS=("cvat-sdk" "cvat-cli" "tests/python") isValueIn () { # Checks if a value is in an array diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index 0a2b54f59b09..2b383930e97f 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -37,8 +37,8 @@ jobs: - name: Get branch name id: get-ref run: | - BRANCH=$(gh api /repos/${{ github.repository }}/pulls/${{ github.event.issue.number }} | jq -r '.head.ref') - echo ::set-output name=ref::${BRANCH} + SHA=$(gh api /repos/${{ github.repository }}/pulls/${{ github.event.issue.number }} | jq -r '.head.sha') + echo ::set-output name=ref::${SHA} - name: Send comment. Test are executing id: send-status diff --git a/.github/workflows/full.yml b/.github/workflows/full.yml index 31c1528c55f7..f76b4207a570 100644 --- a/.github/workflows/full.yml +++ b/.github/workflows/full.yml @@ -64,12 +64,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Create image directory + - name: Create artifact directories run: | mkdir /tmp/cvat_server mkdir /tmp/cvat_ui + mkdir /tmp/cvat_sdk - - name: CVAT server. Build + - name: CVAT server. Build and push uses: docker/build-push-action@v3 with: cache-from: type=local,src=/tmp/cvat_cache_server @@ -78,7 +79,7 @@ jobs: tags: cvat/server outputs: type=docker,dest=/tmp/cvat_server/image.tar - - name: CVAT UI. Build + - name: CVAT UI. Build and push uses: docker/build-push-action@v3 with: cache-from: type=local,src=/tmp/cvat_cache_ui @@ -87,6 +88,19 @@ jobs: tags: cvat/ui outputs: type=docker,dest=/tmp/cvat_ui/image.tar + - name: CVAT SDK. Build + run: | + docker load --input /tmp/cvat_server/image.tar + docker run --rm -v ${PWD}/cvat-sdk/schema/:/transfer \ + --entrypoint /bin/bash -u root cvat/server \ + -c 'python manage.py spectacular --file /transfer/schema.yml' + pip3 install --user -r cvat-sdk/gen/requirements.txt + cd cvat-sdk/ + gen/generate.sh + cd .. + + cp -r cvat-sdk/* /tmp/cvat_sdk/ + - name: Upload CVAT server artifact uses: actions/upload-artifact@v3 with: @@ -99,6 +113,12 @@ jobs: name: cvat_ui path: /tmp/cvat_ui/image.tar + - name: Upload CVAT SDK artifact + uses: actions/upload-artifact@v3 + with: + name: cvat_sdk + path: /tmp/cvat_sdk/ + rest_api: needs: build runs-on: ubuntu-latest @@ -158,25 +178,23 @@ jobs: name: cvat_ui path: /tmp/cvat_ui/ + - name: Download CVAT SDK package + uses: actions/download-artifact@v3 + with: + name: cvat_sdk + path: /tmp/cvat_sdk/ + - name: Load Docker images run: | docker load --input /tmp/cvat_server/image.tar docker load --input /tmp/cvat_ui/image.tar docker tag cvat/server:latest cvat/server:dev - docker tag cvat/ui:latest cvat/server:dev + docker tag cvat/ui:latest cvat/ui:dev docker image ls -a - name: Running REST API and SDK tests run: | - docker run --rm -v ${PWD}/cvat-sdk/schema/:/transfer \ - --entrypoint /bin/bash -u root cvat/server \ - -c 'python manage.py spectacular --file /transfer/schema.yml' - pip3 install --user -r cvat-sdk/gen/requirements.txt - cd cvat-sdk/ - gen/generate.sh - cd .. - - pip3 install --user cvat-sdk/ + pip3 install --user /tmp/cvat_sdk/ pip3 install --user cvat-cli/ pip3 install --user -r tests/python/requirements.txt pytest tests/python -s -v @@ -193,11 +211,9 @@ jobs: - name: Uploading "cvat" container logs as an artifact if: failure() uses: actions/upload-artifact@v2 - env: - LOGS_DIR: "${{ github.workspace }}/rest_api" with: name: container_logs - path: $LOGS_DIR + path: "${{ github.workspace }}/rest_api" unit_testing: needs: build @@ -307,6 +323,7 @@ jobs: -f docker-compose.yml \ -f docker-compose.dev.yml \ -f components/serverless/docker-compose.serverless.yml \ + -f tests/docker-compose.minio.yml \ -f tests/docker-compose.file_share.yml up -d - name: Waiting for server @@ -315,7 +332,7 @@ jobs: run: | max_tries=60 status_code=$(curl -s -o /tmp/server_response -w "%{http_code}" ${API_ABOUT_PAGE}) - while [[ $status_code != "401" && max_tries -gt 0 ]] + while [[ $status_code != "200" && max_tries -gt 0 ]] do echo Number of attempts left: $max_tries echo Status code of response: $status_code @@ -339,13 +356,13 @@ jobs: npx cypress run \ --headed \ --browser chrome \ - --env coverage=false + --env coverage=false \ --config-file cypress_canvas3d.json \ --spec 'cypress/integration/${{ matrix.specs }}/**/*.js,cypress/integration/remove_users_tasks_projects_organizations.js' else npx cypress run \ --browser chrome \ - --env coverage=false + --env coverage=false \ --spec 'cypress/integration/${{ matrix.specs }}/**/*.js,cypress/integration/remove_users_tasks_projects_organizations.js' fi diff --git a/.github/workflows/github_pages.yml b/.github/workflows/github_pages.yml deleted file mode 100644 index 7c24937a4d74..000000000000 --- a/.github/workflows/github_pages.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Github pages - -on: - push: - branches: - - develop - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - submodules: recursive - fetch-depth: 0 - - - name: Setup Hugo - uses: peaceiris/actions-hugo@v2 - with: - hugo-version: '0.83.1' - extended: true - - - name: Setup Node - uses: actions/setup-node@v2 - with: - node-version: '16.x' - - - name: Install npm packages - working-directory: ./site - run: | - npm ci - - - name: Build docs - run: | - pip install gitpython packaging toml - python site/build_docs.py - - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./public - force_orphan: true diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 000000000000..af9b7bb36789 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,102 @@ +name: Helm +on: + pull_request: + types: [edited, ready_for_review, opened, synchronize, reopened] + workflow_dispatch: + +jobs: + check_changes: + runs-on: ubuntu-latest + outputs: + helm_dir_changed: ${{ steps.check_updates.outputs.helm_dir_changed }} + steps: + - uses: jitterbit/get-changed-files@v1 + id: files + continue-on-error: true + + - name: Run check + id: check_updates + env: + PR_FILES_AM: ${{ steps.files.outputs.added_modified }} + PR_FILES_RENAMED: ${{ steps.files.outputs.renamed }} + run: | + PR_FILES="$PR_FILES_AM $PR_FILES_RENAMED" + for FILE in $PR_FILES; do + if [[ $FILE == helm-chart/* ]] ; then + echo "::set-output name=helm_dir_changed::true" + break + fi + done + + testing: + needs: check_changes + if: needs.check_changes.outputs.helm_dir_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Start minikube + uses: medyagh/setup-minikube@latest + + - name: Try the cluster! + run: kubectl get pods -A + + - name: Pull images + run: | + export SHELL=/bin/bash + eval $(minikube -p minikube docker-env) + docker pull cvat/server + docker pull cvat/ui + echo -n "verifying images:" + docker images + + - uses: azure/setup-helm@v3 + with: + version: 'v3.9.4' + + - name: Deploy to minikube + run: | + printf "traefik:\n service:\n externalIPs:\n - $(minikube ip)\n" > helm-chart/values.override.yaml + find cvat/apps/iam/rules -name "*.rego" -and ! -name '*test*' -exec basename {} \; | tar -czf helm-chart/rules.tar.gz -C cvat/apps/iam/rules/ -T - + cd helm-chart + helm dependency update + cd .. + helm upgrade -n default cvat -i --create-namespace helm-chart -f helm-chart/values.yaml -f helm-chart/values.override.yaml + + - name: Update test config + run: | + sed -i -e 's$http://localhost:8080$http://cvat.local:80$g' tests/python/shared/utils/config.py + find tests/python/shared/assets/ -type f -name '*.json' | xargs sed -i -e 's$http://localhost:8080$http://cvat.local$g' + echo "$(minikube ip) cvat.local" | sudo tee -a /etc/hosts + + - name: Wait for CVAT to be ready + run: | + max_tries=30 + while [[ $(kubectl get pods -l component=server -o 'jsonpath={..status.conditions[?(@.type=="Ready")].status}') != "True" && max_tries -gt 0 ]]; do echo "waiting for CVAT pod" && (( max_tries-- )) && sleep 5; done + while [[ $(kubectl get pods -l app.kubernetes.io/name=postgresql -o 'jsonpath={..status.conditions[?(@.type=="Ready")].status}') != "True" && max_tries -gt 0 ]]; do echo "waiting for DB pod" && (( max_tries-- )) && sleep 5; done + kubectl get pods + + - name: Generate schema + run: | + mkdir cvat-sdk/schema + kubectl exec $(kubectl get pods -l component=server -o jsonpath='{.items[0].metadata.name}') -- /bin/bash -c "python manage.py spectacular --file /tmp/schema.yml" + kubectl cp $(kubectl get pods -l component=server -o jsonpath='{.items[0].metadata.name}'):/tmp/schema.yml cvat-sdk/schema/schema.yml + pip3 install --user -r cvat-sdk/gen/requirements.txt + cd cvat-sdk/ + gen/generate.sh + cd .. + + - name: Install test requrements + run: | + pip3 install --user cvat-sdk/ + pip3 install --user cvat-cli/ + pip3 install --user -r tests/python/requirements.txt + + - name: REST API and SDK tests + run: | + pytest --platform=kube \ + --ignore=tests/python/rest_api/test_cloud_storages.py \ + --ignore=tests/python/rest_api/test_analytics.py \ + --ignore=tests/python/rest_api/test_resource_import_export.py \ + -k 'not create_task_with_cloud_storage_files' \ + tests/python diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml index 950fcef41f30..6ef093439887 100644 --- a/.github/workflows/isort.yml +++ b/.github/workflows/isort.yml @@ -17,7 +17,7 @@ jobs: # If different modules use different isort configs, # we need to run isort for each python component group separately. # Otherwise, they all will use the same config. - ENABLED_DIRS=("cvat-sdk" "cvat-cli" "tests/python/sdk" "tests/python/cli") + ENABLED_DIRS=("cvat-sdk" "cvat-cli" "tests/python") isValueIn () { # Checks if a value is in an array diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f85b3a7b43f6..bc425ffe6029 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -65,10 +65,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Create image directory + - name: Create artifact directories run: | mkdir /tmp/cvat_server mkdir /tmp/cvat_ui + mkdir /tmp/cvat_sdk - name: CVAT server. Build and push uses: docker/build-push-action@v3 @@ -88,6 +89,19 @@ jobs: tags: cvat/ui outputs: type=docker,dest=/tmp/cvat_ui/image.tar + - name: CVAT SDK. Build + run: | + docker load --input /tmp/cvat_server/image.tar + docker run --rm -v ${PWD}/cvat-sdk/schema/:/transfer \ + --entrypoint /bin/bash -u root cvat/server \ + -c 'python manage.py spectacular --file /transfer/schema.yml' + pip3 install --user -r cvat-sdk/gen/requirements.txt + cd cvat-sdk/ + gen/generate.sh + cd .. + + cp -r cvat-sdk/* /tmp/cvat_sdk/ + - name: Upload CVAT server artifact uses: actions/upload-artifact@v3 with: @@ -100,7 +114,13 @@ jobs: name: cvat_ui path: /tmp/cvat_ui/image.tar - rest_api: + - name: Upload CVAT SDK artifact + uses: actions/upload-artifact@v3 + with: + name: cvat_sdk + path: /tmp/cvat_sdk/ + + rest_api_testing: needs: build runs-on: ubuntu-latest steps: @@ -125,6 +145,12 @@ jobs: name: cvat_ui path: /tmp/cvat_ui/ + - name: Download CVAT SDK package + uses: actions/download-artifact@v3 + with: + name: cvat_sdk + path: /tmp/cvat_sdk/ + - name: Load Docker images run: | docker load --input /tmp/cvat_server/image.tar @@ -135,22 +161,14 @@ jobs: - name: Running REST API tests run: | - docker run --rm -v ${PWD}/cvat-sdk/schema/:/transfer \ - --entrypoint /bin/bash -u root cvat/server \ - -c 'python manage.py spectacular --file /transfer/schema.yml' - pip3 install --user -r cvat-sdk/gen/requirements.txt - cd cvat-sdk/ - gen/generate.sh - cd .. - - pip3 install --user cvat-sdk/ + pip3 install --user /tmp/cvat_sdk/ pip3 install --user -r tests/python/requirements.txt pytest tests/python/rest_api -k 'GET' -s - name: Creating a log file from cvat containers if: failure() env: - LOGS_DIR: "${{ github.workspace }}/rest_api" + LOGS_DIR: "${{ github.workspace }}/rest_api_testing" run: | mkdir $LOGS_DIR docker logs test_cvat_server_1 > $LOGS_DIR/cvat.log @@ -159,11 +177,9 @@ jobs: - name: Uploading "cvat" container logs as an artifact if: failure() uses: actions/upload-artifact@v2 - env: - LOGS_DIR: "${{ github.workspace }}/rest_api" with: name: container_logs - path: $LOGS_DIR + path: "${{ github.workspace }}/rest_api_testing" unit_testing: needs: build @@ -220,11 +236,10 @@ jobs: - name: Uploading "cvat" container logs as an artifact if: failure() uses: actions/upload-artifact@v2 - env: - LOGS_DIR: "${{ github.workspace }}/unit_testing" with: name: container_logs - path: $LOGS_DIR + path: "${{ github.workspace }}/unit_testing" + e2e_testing: needs: build @@ -269,6 +284,7 @@ jobs: -f docker-compose.yml \ -f docker-compose.dev.yml \ -f components/serverless/docker-compose.serverless.yml \ + -f tests/docker-compose.minio.yml \ -f tests/docker-compose.file_share.yml up -d - name: Waiting for server @@ -277,7 +293,7 @@ jobs: run: | max_tries=60 status_code=$(curl -s -o /tmp/server_response -w "%{http_code}" ${API_ABOUT_PAGE}) - while [[ $status_code != "401" && max_tries -gt 0 ]] + while [[ $status_code != "200" && max_tries -gt 0 ]] do echo Number of attempts left: $max_tries echo Status code of response: $status_code @@ -322,9 +338,64 @@ jobs: name: cypress_screenshots_${{ matrix.specs }} path: ${{ github.workspace }}/tests/cypress/screenshots + generate_github_pages: + if: github.ref == 'refs/heads/develop' + needs: [rest_api_testing, unit_testing, e2e_testing] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + fetch-depth: 0 + + - name: Download CVAT server images + uses: actions/download-artifact@v3 + with: + name: cvat_server + path: /tmp/cvat_server/ + + - name: Download CVAT server images + uses: actions/download-artifact@v3 + with: + name: cvat_sdk + path: /tmp/cvat_sdk/ + + - name: Load Docker images + run: | + docker load --input /tmp/cvat_server/image.tar + + - name: Setup Hugo + uses: peaceiris/actions-hugo@v2 + with: + hugo-version: '0.83.1' + extended: true + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: '16.x' + + - name: Install npm packages + working-directory: ./site + run: | + npm ci + + - name: Build docs + run: | + pip install -r site/requirements.txt + python site/process_sdk_docs.py --input-dir /tmp/cvat_sdk/docs/ --site-root site/ + python site/build_docs.py + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./public + force_orphan: true + publish_dev_images: if: github.ref == 'refs/heads/develop' - needs: [rest_api, unit_testing, e2e_testing] + needs: [rest_api_testing, unit_testing, e2e_testing, generate_github_pages] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/publish_docker_images.yml b/.github/workflows/publish_docker_images.yml index d2f75914a651..8f3d1f1252ba 100644 --- a/.github/workflows/publish_docker_images.yml +++ b/.github/workflows/publish_docker_images.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v2 - name: Build images run: | - CLAM_AV=yes INSTALL_SOURCES=yes docker-compose -f docker-compose.yml -f docker-compose.dev.yml build + CVAT_VERSION=latest CLAM_AV=yes INSTALL_SOURCES=yes docker-compose -f docker-compose.yml -f docker-compose.dev.yml build - name: Login to Docker Hub uses: docker/login-action@v1 with: diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml index a435aa05c360..3c78d9af733e 100644 --- a/.github/workflows/schedule.yml +++ b/.github/workflows/schedule.yml @@ -215,6 +215,8 @@ jobs: run: | docker pull ${{ steps.meta-server.outputs.tags }} docker tag ${{ steps.meta-server.outputs.tags }} cvat/server:dev + docker tag ${{ steps.meta-server.outputs.tags }} cvat/server:latest + docker tag cvat/ui:latest cvat/ui:dev - name: OPA tests run: | @@ -319,6 +321,7 @@ jobs: -f docker-compose.yml \ -f docker-compose.dev.yml \ -f tests/docker-compose.file_share.yml \ + -f tests/docker-compose.minio.yml \ -f components/serverless/docker-compose.serverless.yml up -d - name: Waiting for server @@ -328,7 +331,7 @@ jobs: run: | max_tries=60 status_code=$(curl -s -o /tmp/server_response -w "%{http_code}" ${API_ABOUT_PAGE}) - while [[ $status_code != "401" && max_tries -gt 0 ]] + while [[ $status_code != "200" && max_tries -gt 0 ]] do echo Number of attempts left: $max_tries echo Status code of response: $status_code @@ -338,14 +341,14 @@ jobs: (( max_tries-- )) done - if [[ $status_code != "401" ]]; then + if [[ $status_code != "200" ]]; then echo Response from server is incorrect, output: cat /tmp/server_response fi echo ::set-output name=status_code::${status_code} - name: Fail on bad response from server - if: steps.wait-server.outputs.status_code != '401' + if: steps.wait-server.outputs.status_code != '200' uses: actions/github-script@v3 with: script: | diff --git a/.vscode/launch.json b/.vscode/launch.json index c5bb87012de8..6517d0db82d6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -124,6 +124,25 @@ "env": {}, "console": "internalConsole" }, + { + "name": "server: RQ - webhooks", + "type": "python", + "request": "launch", + "justMyCode": false, + "stopOnEntry": false, + "python": "${command:python.interpreterPath}", + "program": "${workspaceRoot}/manage.py", + "args": [ + "rqworker", + "webhooks", + "--worker-class", + "cvat.simpleworker.SimpleWorker", + ], + "django": true, + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole" + }, { "name": "server: git", "type": "python", @@ -233,6 +252,21 @@ "cwd": "${workspaceFolder}", "console": "integratedTerminal" }, + { + "name": "sdk docs: Postprocess generated docs", + "type": "python", + "request": "launch", + "justMyCode": false, + "stopOnEntry": false, + "python": "${command:python.interpreterPath}", + "program": "${workspaceFolder}/site/process_sdk_docs.py", + "args": [ + "--input-dir", "${workspaceFolder}/cvat-sdk/docs/", + "--site-root", "${workspaceFolder}/site/", + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, { "name": "server: Generate REST API Schema", "type": "python", @@ -285,6 +319,7 @@ "server: django", "server: RQ - default", "server: RQ - low", + "server: RQ - webhooks", "server: RQ - scheduler", "server: git", ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d821b6f734..6ea1bfd5901a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## \[2.2.0] - Unreleased +## \[2.3.0] - Unreleased +### Added +- SDK section in docs () +- An option to enable or disable host certificate checking in CLI () +- REST API tests with skeletons () +- Host schema auto-detection in SDK () +- Server compatibility checks in SDK () +- Add support for Azure Blob Storage connection string authentication() + +### Changed +- `api/docs`, `api/swagger`, `api/schema`, `server/about` endpoints now allow unauthorized access (, ) +- Datumaro version is upgraded to 0.3 (dev) () +- Allowed trailing slashes in the SDK host address () +- Enabled authentication via email () + +### Deprecated +- TDB + +### Removed +- The `--https` option of CLI () + +### Fixed +- Removed a possibly duplicated encodeURI() calls in `server-proxy.ts` to prevent doubly encoding +non-ascii paths while adding files from "Connected file share" (issue #4428) +- Removed unnecessary volumes defined in docker-compose.serverless.yml +() +- Project import/export with skeletons (, + ) +- Shape color is not changed on canvas after changing a label () +- Unstable e2e restore tests () +- IOG and f-BRS serverless function () +- Invisible label item in label constructor when label color background is white, + or close to it () +- Fixed cvat-core ESlint problems () +- Fixed task creation with non-local files via the SDK/CLI + () +- HRNET serverless function () +- Invalid export of segmentation masks when the `background` label gets nonzero id () +- A trailing slash in hostname does't allow SDK to send some requests + () +- Double modal export/backup a task/project () + +### Security +- TDB + +## \[2.2.0] - 2022-09-12 ### Added - Added ability to delete frames from a job based on () - Support of attributes returned by serverless functions based on () @@ -30,7 +75,9 @@ Skeleton (), () - Support for Oracle OCI Buckets () - `cvat-sdk` and `cvat-cli` packages on PyPI () -- Add support for Azure Blob Storage connection string authentication() +- UI part for source and target storages () +- Backup import/export modals () +- Annotations import modal () ### Changed - Bumped nuclio version to 1.8.14 @@ -40,12 +87,6 @@ Skeleton (), () - Clarified meaning of chunking for videos -### Deprecated -- TDB - -### Removed -- TDB - ### Fixed - Task creation progressbar bug - Removed Python dependency ``open3d`` which brought different issues to the building process @@ -65,9 +106,6 @@ Skeleton (), () - Notification with a required manifest file () -### Security -- TDB - ## \[2.1.0] - 2022-04-08 ### Added - Task annotations importing via chunk uploads () diff --git a/README.md b/README.md index 4695d747a332..87c6d9982ad3 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ Start using CVAT online for free: [cvat.ai](https://cvat.ai). Or set it up as a - [Installation guide](https://opencv.github.io/cvat/docs/administration/basics/installation/) - [Manual](https://opencv.github.io/cvat/docs/manual/) - [Contributing](https://opencv.github.io/cvat/docs/contributing/) -- [Django REST API documentation](https://opencv.github.io/cvat/docs/administration/basics/rest_api_guide/) - [Datumaro dataset framework](https://github.com/cvat-ai/datumaro/blob/develop/README.md) -- [Command line interface](https://opencv.github.io/cvat/docs/manual/advanced/cli/) +- [Server API](#api) +- [Python SDK](#sdk) +- [Command line tool](#cli) - [XML annotation format](https://opencv.github.io/cvat/docs/manual/advanced/xml_format/) - [AWS Deployment Guide](https://opencv.github.io/cvat/docs/administration/basics/aws-deployment-guide/) - [Frequently asked questions](https://opencv.github.io/cvat/docs/faq/) @@ -85,11 +86,6 @@ Prebuilt docker images are the easiest way to start using CVAT locally. They are The images have been downloaded more than 1M times so far. -## REST API - -CVAT has a REST API: [documentation](https://opencv.github.io/cvat/docs/administration/basics/rest_api_guide/). -Its current version is `2.0-alpha`. We focus on its improvement, and the API may be changed in the next releases. - ## Screencasts 🎦 Here are some screencasts showing how to use CVAT. @@ -104,6 +100,22 @@ Here are some screencasts showing how to use CVAT. - [Tutorial for polygons](https://youtu.be/C7-r9lZbjBw) - [Semi-automatic segmentation](https://youtu.be/9HszWP_qsRQ) +## API + +- [Documentation](https://opencv.github.io/cvat/docs/api_sdk/api/) + +## SDK + +- Install with `pip install cvat-sdk` +- [PyPI package homepage](https://pypi.org/project/cvat-sdk/) +- [Documentation](https://opencv.github.io/cvat/docs/api_sdk/sdk/) + +## CLI + +- Install with `pip install cvat-cli` +- [PyPI package homepage](https://pypi.org/project/cvat-cli/) +- [Documentation](https://opencv.github.io/cvat/docs/api_sdk/cli/) + ## Supported annotation formats CVAT supports multiple annotation formats. You can select the format after clicking the "Upload annotation" and "Dump diff --git a/components/analytics/docker-compose.analytics.yml b/components/analytics/docker-compose.analytics.yml index 5a459e780774..ceae95219980 100644 --- a/components/analytics/docker-compose.analytics.yml +++ b/components/analytics/docker-compose.analytics.yml @@ -29,7 +29,7 @@ services: cvat_kibana_setup: container_name: cvat_kibana_setup - image: cvat/server:${CVAT_VERSION:-latest} + image: cvat/server:${CVAT_VERSION:-dev} volumes: ['./components/analytics/kibana:/home/django/kibana:ro'] depends_on: ['cvat_server'] working_dir: '/home/django' diff --git a/components/serverless/docker-compose.serverless.yml b/components/serverless/docker-compose.serverless.yml index 22e03e61b0e0..bfd829b8de5c 100644 --- a/components/serverless/docker-compose.serverless.yml +++ b/components/serverless/docker-compose.serverless.yml @@ -17,6 +17,11 @@ services: NUCLIO_DASHBOARD_DEFAULT_FUNCTION_MOUNT_MODE: 'volume' ports: - '8070:8070' + logging: + driver: "json-file" + options: + max-size: 100m + max-file: "3" cvat_server: environment: @@ -27,6 +32,3 @@ services: cvat_worker_low: extra_hosts: - "host.docker.internal:host-gateway" - -volumes: - cvat_events: diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index ee8180b44cbb..c9cee7a7b476 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.15.3", + "version": "2.15.4", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index c120cd730749..9402a813d24c 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -1864,7 +1864,7 @@ export class CanvasViewImpl implements CanvasView, Listener { drawnStateDescriptions.length !== stateDescriptions.length || drawnStateDescriptions.some((desc: string, id: number): boolean => desc !== stateDescriptions[id]) ) { - // need to remove created text and create it again + // remove created text and create it again if (text) { text.remove(); this.svgTexts[state.clientID] = this.addText(state); @@ -1884,6 +1884,15 @@ export class CanvasViewImpl implements CanvasView, Listener { } } + if (drawnState.label.id !== state.label.id || drawnState.color !== state.color) { + // update shape color if necessary + if (shape) { + shape.attr({ + ...this.getShapeColorization(state), + }); + } + } + if ( drawnState.group.id !== state.group.id || drawnState.group.color !== state.group.color ) { diff --git a/cvat-cli/src/cvat_cli/__main__.py b/cvat-cli/src/cvat_cli/__main__.py index 0ac29d5e6972..ce32b832fdde 100755 --- a/cvat-cli/src/cvat_cli/__main__.py +++ b/cvat-cli/src/cvat_cli/__main__.py @@ -6,9 +6,11 @@ import logging import sys from http.client import HTTPConnection +from types import SimpleNamespace from typing import List -from cvat_sdk import exceptions, make_client +from cvat_sdk import exceptions +from cvat_sdk.core.client import Client, Config from cvat_cli.cli import CLI from cvat_cli.parser import get_action_args, make_cmdline_parser @@ -28,6 +30,21 @@ def configure_logger(level): HTTPConnection.debuglevel = 1 +def build_client(parsed_args: SimpleNamespace, logger: logging.Logger) -> Client: + config = Config(verify_ssl=not parsed_args.insecure) + + url = parsed_args.server_host + if parsed_args.server_port: + url += f":{parsed_args.server_port}" + + return Client( + url=url, + logger=logger, + config=config, + check_server_version=False, # version is checked after auth to support versions < 2.3 + ) + + def main(args: List[str] = None): actions = { "create": CLI.tasks_create, @@ -43,9 +60,7 @@ def main(args: List[str] = None): parsed_args = parser.parse_args(args) configure_logger(parsed_args.loglevel) - with make_client(parsed_args.server_host, port=parsed_args.server_port) as client: - client.logger = logger - + with build_client(parsed_args, logger=logger) as client: action_args = get_action_args(parser, parsed_args) try: cli = CLI(client=client, credentials=parsed_args.auth) diff --git a/cvat-cli/src/cvat_cli/cli.py b/cvat-cli/src/cvat_cli/cli.py index 4609f95491ab..00122341cf58 100644 --- a/cvat-cli/src/cvat_cli/cli.py +++ b/cvat-cli/src/cvat_cli/cli.py @@ -23,6 +23,8 @@ def __init__(self, client: Client, credentials: Tuple[str, str]): self.client.login(credentials) + self.client.check_server_version(fail_if_unsupported=False) + def tasks_list(self, *, use_json_output: bool = False, **kwargs): """List all tasks in either basic or JSON format.""" results = self.client.tasks.list(return_json=use_json_output, **kwargs) diff --git a/cvat-cli/src/cvat_cli/parser.py b/cvat-cli/src/cvat_cli/parser.py index 43bca8ebc674..24d4709dca6a 100644 --- a/cvat-cli/src/cvat_cli/parser.py +++ b/cvat-cli/src/cvat_cli/parser.py @@ -47,6 +47,11 @@ def make_cmdline_parser() -> argparse.ArgumentParser: description="Perform common operations related to CVAT tasks.\n\n" ) parser.add_argument("--version", action="version", version=VERSION) + parser.add_argument( + "--insecure", + action="store_true", + help="Allows to disable SSL certificate check", + ) task_subparser = parser.add_subparsers(dest="action") diff --git a/cvat-core/package.json b/cvat-core/package.json index 76d656d8da13..d7941e1819d6 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "6.0.2", + "version": "7.0.1", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", "scripts": { @@ -29,7 +29,7 @@ "dependencies": { "axios": "^0.27.2", "browser-or-node": "^2.0.0", - "cvat-data": "file:../cvat-data", + "cvat-data": "link:./../cvat-data", "detect-browser": "^5.2.1", "error-stack-parser": "^2.0.2", "form-data": "^4.0.0", diff --git a/cvat-core/src/annotations-saver.ts b/cvat-core/src/annotations-saver.ts index fdc157aacc97..41800d6a00bd 100644 --- a/cvat-core/src/annotations-saver.ts +++ b/cvat-core/src/annotations-saver.ts @@ -4,7 +4,7 @@ // SPDX-License-Identifier: MIT (() => { - const serverProxy = require('./server-proxy'); + const serverProxy = require('./server-proxy').default; const { Task } = require('./session'); const { ScriptingError } = require('./exceptions'); diff --git a/cvat-core/src/annotations.ts b/cvat-core/src/annotations.ts index 3b6d28362bf8..046ae0a1ece9 100644 --- a/cvat-core/src/annotations.ts +++ b/cvat-core/src/annotations.ts @@ -1,404 +1,388 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const serverProxy = require('./server-proxy'); - const Collection = require('./annotations-collection'); - const AnnotationsSaver = require('./annotations-saver'); - const AnnotationsHistory = require('./annotations-history').default; - const { checkObjectType } = require('./common'); - const { Project } = require('./project'); - const { Task, Job } = require('./session'); - const { Loader } = require('./annotation-formats'); - const { ScriptingError, DataError, ArgumentError } = require('./exceptions'); - const { getDeletedFrames } = require('./frames'); - - const jobCache = new WeakMap(); - const taskCache = new WeakMap(); - - function getCache(sessionType) { - if (sessionType === 'task') { - return taskCache; - } +import { Storage } from './storage'; - if (sessionType === 'job') { - return jobCache; - } +const serverProxy = require('./server-proxy').default; +const Collection = require('./annotations-collection'); +const AnnotationsSaver = require('./annotations-saver'); +const AnnotationsHistory = require('./annotations-history').default; +const { checkObjectType } = require('./common'); +const Project = require('./project').default; +const { Task, Job } = require('./session'); +const { ScriptingError, DataError, ArgumentError } = require('./exceptions'); +const { getDeletedFrames } = require('./frames'); - throw new ScriptingError(`Unknown session type was received ${sessionType}`); - } - - async function getAnnotationsFromServer(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); - - if (!cache.has(session)) { - const rawAnnotations = await serverProxy.annotations.getAnnotations(sessionType, session.id); - - // Get meta information about frames - const startFrame = sessionType === 'job' ? session.startFrame : 0; - const stopFrame = sessionType === 'job' ? session.stopFrame : session.size - 1; - const frameMeta = {}; - for (let i = startFrame; i <= stopFrame; i++) { - frameMeta[i] = await session.frames.get(i); - } - frameMeta.deleted_frames = await getDeletedFrames(sessionType, session.id); - - const history = new AnnotationsHistory(); - const collection = new Collection({ - labels: session.labels || session.task.labels, - history, - startFrame, - stopFrame, - frameMeta, - }); - - // eslint-disable-next-line no-unsanitized/method - collection.import(rawAnnotations); - const saver = new AnnotationsSaver(rawAnnotations.version, collection, session); - cache.set(session, { collection, saver, history }); - } +const jobCache = new WeakMap(); +const taskCache = new WeakMap(); + +function getCache(sessionType) { + if (sessionType === 'task') { + return taskCache; } - async function closeSession(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + if (sessionType === 'job') { + return jobCache; + } - if (cache.has(session)) { - cache.delete(session); - } + throw new ScriptingError(`Unknown session type was received ${sessionType}`); +} + +async function getAnnotationsFromServer(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (!cache.has(session)) { + const rawAnnotations = await serverProxy.annotations.getAnnotations(sessionType, session.id); + + // Get meta information about frames + const startFrame = sessionType === 'job' ? session.startFrame : 0; + const stopFrame = sessionType === 'job' ? session.stopFrame : session.size - 1; + const frameMeta = {}; + for (let i = startFrame; i <= stopFrame; i++) { + frameMeta[i] = await session.frames.get(i); + } + frameMeta.deleted_frames = await getDeletedFrames(sessionType, session.id); + + const history = new AnnotationsHistory(); + const collection = new Collection({ + labels: session.labels || session.task.labels, + history, + startFrame, + stopFrame, + frameMeta, + }); + + // eslint-disable-next-line no-unsanitized/method + collection.import(rawAnnotations); + const saver = new AnnotationsSaver(rawAnnotations.version, collection, session); + cache.set(session, { collection, saver, history }); } +} - async function getAnnotations(session, frame, allTracks, filters) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); +export async function closeSession(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - if (cache.has(session)) { - return cache.get(session).collection.get(frame, allTracks, filters); - } + if (cache.has(session)) { + cache.delete(session); + } +} - await getAnnotationsFromServer(session); +export async function getAnnotations(session, frame, allTracks, filters) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (cache.has(session)) { return cache.get(session).collection.get(frame, allTracks, filters); } - async function saveAnnotations(session, onUpdate) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + await getAnnotationsFromServer(session); + return cache.get(session).collection.get(frame, allTracks, filters); +} - if (cache.has(session)) { - await cache.get(session).saver.save(onUpdate); - } +export async function saveAnnotations(session, onUpdate) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it + if (cache.has(session)) { + await cache.get(session).saver.save(onUpdate); } - function searchAnnotations(session, filters, frameFrom, frameTo) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + // If a collection wasn't uploaded, than it wasn't changed, finally we shouldn't save it +} - if (cache.has(session)) { - return cache.get(session).collection.search(filters, frameFrom, frameTo); - } +export function searchAnnotations(session, filters, frameFrom, frameTo) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.search(filters, frameFrom, frameTo); } - function searchEmptyFrame(session, frameFrom, frameTo) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.searchEmpty(frameFrom, frameTo); - } +export function searchEmptyFrame(session, frameFrom, frameTo) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.searchEmpty(frameFrom, frameTo); } - function mergeAnnotations(session, objectStates) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.merge(objectStates); - } +export function mergeAnnotations(session, objectStates) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.merge(objectStates); } - function splitAnnotations(session, objectState, frame) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.split(objectState, frame); - } +export function splitAnnotations(session, objectState, frame) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.split(objectState, frame); } - function groupAnnotations(session, objectStates, reset) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.group(objectStates, reset); - } +export function groupAnnotations(session, objectStates, reset) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.group(objectStates, reset); } - function hasUnsavedChanges(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).saver.hasUnsavedChanges(); - } +export function hasUnsavedChanges(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - return false; + if (cache.has(session)) { + return cache.get(session).saver.hasUnsavedChanges(); } - async function clearAnnotations(session, reload, startframe, endframe, delTrackKeyframesOnly) { - checkObjectType('reload', reload, 'boolean', null); - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + return false; +} - if (cache.has(session)) { - cache.get(session).collection.clear(startframe, endframe, delTrackKeyframesOnly); - } +export async function clearAnnotations(session, reload, startframe, endframe, delTrackKeyframesOnly) { + checkObjectType('reload', reload, 'boolean', null); + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - if (reload) { - cache.delete(session); - await getAnnotationsFromServer(session); - } + if (cache.has(session)) { + cache.get(session).collection.clear(startframe, endframe, delTrackKeyframesOnly); } - function annotationsStatistics(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + if (reload) { + cache.delete(session); + await getAnnotationsFromServer(session); + } +} - if (cache.has(session)) { - return cache.get(session).collection.statistics(); - } +export function annotationsStatistics(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.statistics(); } - function putAnnotations(session, objectStates) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.put(objectStates); - } +export function putAnnotations(session, objectStates) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.put(objectStates); } - function selectObject(session, objectStates, x, y) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); - - if (cache.has(session)) { - return cache.get(session).collection.select(objectStates, x, y); - } + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); - } +export function selectObject(session, objectStates, x, y) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - async function uploadAnnotations(session, file, loader) { - const sessionType = session instanceof Task ? 'task' : 'job'; - if (!(loader instanceof Loader)) { - throw new ArgumentError('A loader must be instance of Loader class'); - } - await serverProxy.annotations.uploadAnnotations(sessionType, session.id, file, loader.name); + if (cache.has(session)) { + return cache.get(session).collection.select(objectStates, x, y); } - function importAnnotations(session, data) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - // eslint-disable-next-line no-unsanitized/method - return cache.get(session).collection.import(data); - } +export function importCollection(session, data) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + // eslint-disable-next-line no-unsanitized/method + return cache.get(session).collection.import(data); } - function exportAnnotations(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).collection.export(); - } +export function exportCollection(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).collection.export(); } - async function exportDataset(instance, format, name, saveImages = false) { - if (!(format instanceof String || typeof format === 'string')) { - throw new ArgumentError('Format must be a string'); - } - if (!(instance instanceof Task || instance instanceof Project || instance instanceof Job)) { - throw new ArgumentError('A dataset can only be created from a job, task or project'); - } - if (typeof saveImages !== 'boolean') { - throw new ArgumentError('Save images parameter must be a boolean'); - } + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} + +export async function exportDataset( + instance, + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + name?: string, +) { + if (!(instance instanceof Task || instance instanceof Project || instance instanceof Job)) { + throw new ArgumentError('A dataset can only be created from a job, task or project'); + } - let result = null; - if (instance instanceof Task) { - result = await serverProxy.tasks.exportDataset(instance.id, format, name, saveImages); - } else if (instance instanceof Job) { - result = await serverProxy.tasks.exportDataset(instance.taskId, format, name, saveImages); - } else { - result = await serverProxy.projects.exportDataset(instance.id, format, name, saveImages); - } + let result = null; + if (instance instanceof Task) { + result = await serverProxy.tasks + .exportDataset(instance.id, format, saveImages, useDefaultSettings, targetStorage, name); + } else if (instance instanceof Job) { + result = await serverProxy.jobs + .exportDataset(instance.id, format, saveImages, useDefaultSettings, targetStorage, name); + } else { + result = await serverProxy.projects + .exportDataset(instance.id, format, saveImages, useDefaultSettings, targetStorage, name); + } - return result; + return result; +} + +export function importDataset( + instance: any, + format: string, + useDefaultSettings: boolean, + sourceStorage: Storage, + file: File | string, + updateStatusCallback = () => {}, +) { + if (!(instance instanceof Project || instance instanceof Task || instance instanceof Job)) { + throw new ArgumentError('Instance should be a Project || Task || Job instance'); + } + if (!(typeof updateStatusCallback === 'function')) { + throw new ArgumentError('Callback should be a function'); + } + if (typeof file === 'string' && !file.toLowerCase().endsWith('.zip')) { + throw new ArgumentError('File should be file instance with ZIP extension'); + } + if (file instanceof File && !(['application/zip', 'application/x-zip-compressed'].includes(file.type))) { + throw new ArgumentError('File should be file instance with ZIP extension'); } - function importDataset(instance, format, file, updateStatusCallback = () => {}) { - if (!(typeof format === 'string')) { - throw new ArgumentError('Format must be a string'); - } - if (!(instance instanceof Project)) { - throw new ArgumentError('Instance should be a Project instance'); - } - if (!(typeof updateStatusCallback === 'function')) { - throw new ArgumentError('Callback should be a function'); - } - if (!(['application/zip', 'application/x-zip-compressed'].includes(file.type))) { - throw new ArgumentError('File should be file instance with ZIP extension'); - } - return serverProxy.projects.importDataset(instance.id, format, file, updateStatusCallback); + if (instance instanceof Project) { + return serverProxy.projects + .importDataset(instance.id, format, useDefaultSettings, sourceStorage, file, updateStatusCallback); } - function getHistory(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + const instanceType = instance instanceof Task ? 'task' : 'job'; + return serverProxy.annotations + .uploadAnnotations(instanceType, instance.id, format, useDefaultSettings, sourceStorage, file); +} - if (cache.has(session)) { - return cache.get(session).history; - } +export function getHistory(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).history; } - async function undoActions(session, count) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).history.undo(count); - } +export async function undoActions(session, count) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).history.undo(count); } - async function redoActions(session, count) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).history.redo(count); - } +export async function redoActions(session, count) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).history.redo(count); } - function freezeHistory(session, frozen) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).history.freeze(frozen); - } +export function freezeHistory(session, frozen) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).history.freeze(frozen); } - function clearActions(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).history.clear(); - } +export function clearActions(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); + if (cache.has(session)) { + return cache.get(session).history.clear(); } - function getActions(session) { - const sessionType = session instanceof Task ? 'task' : 'job'; - const cache = getCache(sessionType); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} - if (cache.has(session)) { - return cache.get(session).history.get(); - } +export function getActions(session) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (cache.has(session)) { + return cache.get(session).history.get(); + } - throw new DataError( - 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', - ); - } - - module.exports = { - getAnnotations, - putAnnotations, - saveAnnotations, - hasUnsavedChanges, - mergeAnnotations, - searchAnnotations, - searchEmptyFrame, - splitAnnotations, - groupAnnotations, - clearAnnotations, - annotationsStatistics, - selectObject, - uploadAnnotations, - importAnnotations, - exportAnnotations, - exportDataset, - importDataset, - undoActions, - redoActions, - freezeHistory, - getHistory, - clearActions, - getActions, - closeSession, - }; -})(); + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); +} diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 392ea0126e87..22ea2ae1ff97 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -1,4 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -6,7 +7,7 @@ const config = require('./config'); (() => { const PluginRegistry = require('./plugins').default; - const serverProxy = require('./server-proxy'); + const serverProxy = require('./server-proxy').default; const lambdaManager = require('./lambda-manager'); const { isBoolean, @@ -17,13 +18,14 @@ const config = require('./config'); checkObjectType, } = require('./common'); - const User = require('./user'); + const User = require('./user').default; const { AnnotationFormats } = require('./annotation-formats'); const { ArgumentError } = require('./exceptions'); const { Task, Job } = require('./session'); - const { Project } = require('./project'); - const { CloudStorage } = require('./cloud-storage'); - const Organization = require('./organization'); + const Project = require('./project').default; + const CloudStorage = require('./cloud-storage').default; + const Organization = require('./organization').default; + const Webhook = require('./webhook').default; function implementAPI(cvat) { cvat.plugins.list.implementation = PluginRegistry.list; @@ -286,6 +288,39 @@ const config = require('./config'); config.organizationID = null; }; + cvat.webhooks.get.implementation = async (filter) => { + checkFilter(filter, { + page: isInteger, + id: isInteger, + projectId: isInteger, + filter: isString, + search: isString, + sort: isString, + }); + + checkExclusiveFields(filter, ['id', 'projectId'], ['page']); + const searchParams = {}; + for (const key of Object.keys(filter)) { + if (['page', 'id', 'filter', 'search', 'sort'].includes(key)) { + searchParams[key] = filter[key]; + } + } + + if (filter.projectId) { + if (searchParams.filter) { + const parsed = JSON.parse(searchParams.filter); + searchParams.filter = JSON.stringify({ and: [parsed, { '==': [{ var: 'project_id' }, filter.projectId] }] }); + } else { + searchParams.filter = JSON.stringify({ and: [{ '==': [{ var: 'project_id' }, filter.projectId] }] }); + } + } + + const webhooksData = await serverProxy.webhooks.get(searchParams); + const webhooks = webhooksData.map((webhookData) => new Webhook(webhookData)); + webhooks.count = webhooksData.count; + return webhooks; + }; + return cvat; } diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 77f1eb00a285..2225d95b9705 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -1,4 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,20 +10,21 @@ function build() { const PluginRegistry = require('./plugins').default; - const loggerStorage = require('./logger-storage'); - const Log = require('./log'); + const loggerStorage = require('./logger-storage').default; + const { Log } = require('./log'); const ObjectState = require('./object-state').default; const Statistics = require('./statistics'); - const Comment = require('./comment'); - const Issue = require('./issue'); + const Comment = require('./comment').default; + const Issue = require('./issue').default; const { Job, Task } = require('./session'); - const { Project } = require('./project'); - const implementProject = require('./project-implementation'); + const Project = require('./project').default; + const implementProject = require('./project-implementation').default; const { Attribute, Label } = require('./labels'); const MLModel = require('./ml-model'); const { FrameData } = require('./frames'); - const { CloudStorage } = require('./cloud-storage'); - const Organization = require('./organization'); + const CloudStorage = require('./cloud-storage').default; + const Organization = require('./organization').default; + const Webhook = require('./webhook').default; const enums = require('./enums'); @@ -30,7 +32,7 @@ function build() { Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError, } = require('./exceptions'); - const User = require('./user'); + const User = require('./user').default; const pjson = require('../package.json'); const config = require('./config'); @@ -843,6 +845,26 @@ function build() { return result; }, }, + /** + * This namespace could be used to get webhooks list from the server + * @namespace webhooks + * @memberof module:API.cvat + */ + webhooks: { + /** + * Method returns a list of organizations + * @method get + * @async + * @memberof module:API.cvat.webhooks + * @returns {module:API.cvat.classes.Webhook[]} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async get(filter: any) { + const result = await PluginRegistry.apiWrapper(cvat.webhooks.get, filter); + return result; + }, + }, /** * Namespace is used for access to classes * @namespace classes @@ -864,6 +886,7 @@ function build() { FrameData, CloudStorage, Organization, + Webhook, }, }; diff --git a/cvat-core/src/cloud-storage.ts b/cvat-core/src/cloud-storage.ts index 541c2d39aad4..57a5eb282610 100644 --- a/cvat-core/src/cloud-storage.ts +++ b/cvat-core/src/cloud-storage.ts @@ -1,531 +1,393 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const PluginRegistry = require('./plugins').default; - const serverProxy = require('./server-proxy'); - const { isBrowser, isNode } = require('browser-or-node'); - const { ArgumentError } = require('./exceptions'); - const { CloudStorageCredentialsType, CloudStorageProviderType } = require('./enums'); +import { isBrowser, isNode } from 'browser-or-node'; +import PluginRegistry from './plugins'; +import serverProxy from './server-proxy'; +import { ArgumentError } from './exceptions'; +import { CloudStorageCredentialsType, CloudStorageProviderType, CloudStorageStatus } from './enums'; +import User from './user'; - function validateNotEmptyString(value) { - if (typeof value !== 'string') { - throw new ArgumentError(`Value must be a string. ${typeof value} was found`); - } else if (!value.trim().length) { - throw new ArgumentError('Value mustn\'t be empty string'); - } +function validateNotEmptyString(value: string): void { + if (typeof value !== 'string') { + throw new ArgumentError(`Value must be a string. ${typeof value} was found`); + } else if (!value.trim().length) { + throw new ArgumentError('Value mustn\'t be empty string'); } +} - /** - * Class representing a cloud storage - * @memberof module:API.cvat.classes - */ - class CloudStorage { - constructor(initialData) { - const data = { - id: undefined, - display_name: undefined, - description: undefined, - credentials_type: undefined, - provider_type: undefined, - resource: undefined, - account_name: undefined, - key: undefined, - secret_key: undefined, - session_token: undefined, - connection_string: undefined, - key_file: undefined, - specific_attributes: undefined, - owner: undefined, - created_date: undefined, - updated_date: undefined, - manifest_path: undefined, - manifests: undefined, - }; +interface RawCloudStorageData { + id?: number; + display_name?: string; + description?: string, + credentials_type?: CloudStorageCredentialsType, + provider_type?: CloudStorageProviderType, + resource?: string, + account_name?: string, + key?: string, + secret_key?: string, + session_token?: string, + key_file?: File, + specific_attributes?: string, + owner?: any, + created_date?: string, + updated_date?: string, + manifest_path?: string, + manifests?: string[], +} - for (const property in data) { - if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { - data[property] = initialData[property]; - } +export default class CloudStorage { + public readonly id: number; + public displayName: string; + public description: string; + public accountName: string; + public accessKey: string; + public secretKey: string; + public token: string; + public keyFile: File; + public resource: string; + public manifestPath: string; + public provider_type: CloudStorageProviderType; + public credentials_type: CloudStorageCredentialsType; + public specificAttributes: string; + public manifests: string[]; + public readonly owner: User; + public readonly createdDate: string; + public readonly updatedDate: string; + + constructor(initialData: RawCloudStorageData) { + const data: RawCloudStorageData = { + id: undefined, + display_name: undefined, + description: undefined, + credentials_type: undefined, + provider_type: undefined, + resource: undefined, + account_name: undefined, + key: undefined, + secret_key: undefined, + session_token: undefined, + key_file: undefined, + specific_attributes: undefined, + owner: undefined, + created_date: undefined, + updated_date: undefined, + manifest_path: undefined, + manifests: undefined, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; } + } - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - */ - id: { - get: () => data.id, + Object.defineProperties( + this, + Object.freeze({ + id: { + get: () => data.id, + }, + displayName: { + get: () => data.display_name, + set: (value) => { + validateNotEmptyString(value); + data.display_name = value; }, - /** - * Storage name - * @name displayName - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - displayName: { - get: () => data.display_name, - set: (value) => { - validateNotEmptyString(value); - data.display_name = value; - }, + }, + description: { + get: () => data.description, + set: (value) => { + if (typeof value !== 'string') { + throw new ArgumentError('Value must be string'); + } + data.description = value; }, - /** - * Storage description - * @name description - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - description: { - get: () => data.description, - set: (value) => { - if (typeof value !== 'string') { - throw new ArgumentError('Value must be string'); - } - data.description = value; - }, + }, + accountName: { + get: () => data.account_name, + set: (value) => { + validateNotEmptyString(value); + data.account_name = value; }, - /** - * Azure account name - * @name accountName - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - accountName: { - get: () => data.account_name, - set: (value) => { - validateNotEmptyString(value); - data.account_name = value; - }, + }, + accessKey: { + get: () => data.key, + set: (value) => { + validateNotEmptyString(value); + data.key = value; }, - /** - * AWS access key id - * @name accessKey - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - accessKey: { - get: () => data.key, - set: (value) => { - validateNotEmptyString(value); - data.key = value; - }, + }, + secretKey: { + get: () => data.secret_key, + set: (value) => { + validateNotEmptyString(value); + data.secret_key = value; }, - /** - * AWS secret key - * @name secretKey - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - secretKey: { - get: () => data.secret_key, - set: (value) => { - validateNotEmptyString(value); - data.secret_key = value; - }, + }, + token: { + get: () => data.session_token, + set: (value) => { + validateNotEmptyString(value); + data.session_token = value; }, - /** - * Session token - * @name token - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - token: { - get: () => data.session_token, - set: (value) => { - validateNotEmptyString(value); - data.session_token = value; - }, + }, + keyFile: { + get: () => data.key_file, + set: (file) => { + if (file instanceof File) { + data.key_file = file; + } else { + throw new ArgumentError(`Should be a file. ${typeof file} was found`); + } }, - /** - * Connection String - * @name connectionString - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - connectionString: { - get: () => data.connection_string, - set: (value) => { - validateNotEmptyString(value); - data.connection_string = value; - }, + }, + resource: { + get: () => data.resource, + set: (value) => { + validateNotEmptyString(value); + data.resource = value; }, - /** - * Key file - * @name keyFile - * @type {File} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - keyFile: { - get: () => data.key_file, - set: (file) => { - if (file instanceof File) { - data.key_file = file; - } else { - throw new ArgumentError(`Should be a file. ${typeof file} was found`); - } - }, + }, + manifestPath: { + get: () => data.manifest_path, + set: (value) => { + validateNotEmptyString(value); + data.manifest_path = value; }, - /** - * Unique resource name - * @name resource - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - resource: { - get: () => data.resource, - set: (value) => { - validateNotEmptyString(value); - data.resource = value; - }, + }, + providerType: { + get: () => data.provider_type, + set: (key) => { + if (key !== undefined && !!CloudStorageProviderType[key]) { + data.provider_type = CloudStorageProviderType[key]; + } else { + throw new ArgumentError('Value must be one CloudStorageProviderType keys'); + } }, - /** - * @name manifestPath - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - manifestPath: { - get: () => data.manifest_path, - set: (value) => { - validateNotEmptyString(value); - data.manifest_path = value; - }, + }, + credentialsType: { + get: () => data.credentials_type, + set: (key) => { + if (key !== undefined && !!CloudStorageCredentialsType[key]) { + data.credentials_type = CloudStorageCredentialsType[key]; + } else { + throw new ArgumentError('Value must be one CloudStorageCredentialsType keys'); + } }, - /** - * @name providerType - * @type {module:API.cvat.enums.ProviderType} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - providerType: { - get: () => data.provider_type, - set: (key) => { - if (key !== undefined && !!CloudStorageProviderType[key]) { - data.provider_type = CloudStorageProviderType[key]; - } else { - throw new ArgumentError('Value must be one CloudStorageProviderType keys'); + }, + specificAttributes: { + get: () => data.specific_attributes, + set: (attributesValue) => { + if (typeof attributesValue === 'string') { + const attrValues = new URLSearchParams( + Array.from(new URLSearchParams(attributesValue).entries()).filter( + ([key, value]) => !!key && !!value, + ), + ).toString(); + if (!attrValues) { + throw new ArgumentError('Value must match the key1=value1&key2=value2'); } - }, + data.specific_attributes = attributesValue; + } else { + throw new ArgumentError('Value must be a string'); + } }, - /** - * @name credentialsType - * @type {module:API.cvat.enums.CloudStorageCredentialsType} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - credentialsType: { - get: () => data.credentials_type, - set: (key) => { - if (key !== undefined && !!CloudStorageCredentialsType[key]) { - data.credentials_type = CloudStorageCredentialsType[key]; - } else { - throw new ArgumentError('Value must be one CloudStorageCredentialsType keys'); - } - }, - }, - /** - * @name specificAttributes - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - specificAttributes: { - get: () => data.specific_attributes, - set: (attributesValue) => { - if (typeof attributesValue === 'string') { - const attrValues = new URLSearchParams( - Array.from(new URLSearchParams(attributesValue).entries()).filter( - ([key, value]) => !!key && !!value, - ), - ).toString(); - if (!attrValues) { - throw new ArgumentError('Value must match the key1=value1&key2=value2'); + }, + owner: { + get: () => data.owner, + }, + createdDate: { + get: () => data.created_date, + }, + updatedDate: { + get: () => data.updated_date, + }, + manifests: { + get: () => data.manifests, + set: (manifests) => { + if (Array.isArray(manifests)) { + for (const elem of manifests) { + if (typeof elem !== 'string') { + throw new ArgumentError('Each element of the manifests array must be a string'); } - data.specific_attributes = attributesValue; - } else { - throw new ArgumentError('Value must be a string'); } - }, - }, - /** - * Instance of a user who has created the cloud storage - * @name owner - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - */ - owner: { - get: () => data.owner, - }, - /** - * @name createdDate - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - */ - createdDate: { - get: () => data.created_date, + data.manifests = manifests; + } else { + throw new ArgumentError('Value must be an array'); + } }, - /** - * @name updatedDate - * @type {string} - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - */ - updatedDate: { - get: () => data.updated_date, - }, - /** - * @name manifests - * @type {string[]} - * @memberof module:API.cvat.classes.CloudStorage - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - manifests: { - get: () => data.manifests, - set: (manifests) => { - if (Array.isArray(manifests)) { - for (const elem of manifests) { - if (typeof elem !== 'string') { - throw new ArgumentError('Each element of the manifests array must be a string'); - } - } - data.manifests = manifests; - } else { - throw new ArgumentError('Value must be an array'); - } - }, - }, - }), - ); - } + }, + }), + ); + } - /** - * Method updates data of a created cloud storage or creates new cloud storage - * @method save - * @returns {module:API.cvat.classes.CloudStorage} - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async save() { - const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.save); - return result; - } + // Method updates data of a created cloud storage or creates new cloud storage + public async save(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.save); + return result; + } - /** - * Method deletes a cloud storage from a server - * @method delete - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async delete() { - const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.delete); - return result; - } + public async delete(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.delete); + return result; + } - /** - * Method returns cloud storage content - * @method getContent - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async getContent() { - const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getContent); - return result; - } + public async getContent(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getContent); + return result; + } - /** - * Method returns the cloud storage preview - * @method getPreview - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async getPreview() { - const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getPreview); - return result; - } + public async getPreview(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getPreview); + return result; + } - /** - * Method returns cloud storage status - * @method getStatus - * @memberof module:API.cvat.classes.CloudStorage - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async getStatus() { - const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getStatus); - return result; - } + public async getStatus(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, CloudStorage.prototype.getStatus); + return result; } +} - CloudStorage.prototype.save.implementation = async function () { - function prepareOptionalFields(cloudStorageInstance) { - const data = {}; - if (cloudStorageInstance.description !== undefined) { - data.description = cloudStorageInstance.description; - } +Object.defineProperties(CloudStorage.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(): Promise { + function prepareOptionalFields(cloudStorageInstance: CloudStorage): RawCloudStorageData { + const data: RawCloudStorageData = {}; + if (cloudStorageInstance.description !== undefined) { + data.description = cloudStorageInstance.description; + } - if (cloudStorageInstance.accountName) { - data.account_name = cloudStorageInstance.accountName; - } + if (cloudStorageInstance.accountName) { + data.account_name = cloudStorageInstance.accountName; + } - if (cloudStorageInstance.accessKey) { - data.key = cloudStorageInstance.accessKey; - } + if (cloudStorageInstance.accessKey) { + data.key = cloudStorageInstance.accessKey; + } - if (cloudStorageInstance.secretKey) { - data.secret_key = cloudStorageInstance.secretKey; - } + if (cloudStorageInstance.secretKey) { + data.secret_key = cloudStorageInstance.secretKey; + } - if (cloudStorageInstance.token) { - data.session_token = cloudStorageInstance.token; - } + if (cloudStorageInstance.token) { + data.session_token = cloudStorageInstance.token; + } - if (cloudStorageInstance.connectionString) { - data.connection_string = cloudStorageInstance.connectionString; - } + if (cloudStorageInstance.keyFile) { + data.key_file = cloudStorageInstance.keyFile; + } - if (cloudStorageInstance.keyFile) { - data.key_file = cloudStorageInstance.keyFile; + if (cloudStorageInstance.specificAttributes !== undefined) { + data.specific_attributes = cloudStorageInstance.specificAttributes; + } + return data; } + // update + if (typeof this.id !== 'undefined') { + // provider_type and recource should not change; + // send to the server only the values that have changed + const initialData: RawCloudStorageData = {}; + if (this.displayName) { + initialData.display_name = this.displayName; + } + if (this.credentialsType) { + initialData.credentials_type = this.credentialsType; + } - if (cloudStorageInstance.specificAttributes !== undefined) { - data.specific_attributes = cloudStorageInstance.specificAttributes; - } - return data; - } - // update - if (typeof this.id !== 'undefined') { - // provider_type and recource should not change; - // send to the server only the values that have changed - const initialData = {}; - if (this.displayName) { - initialData.display_name = this.displayName; - } - if (this.credentialsType) { - initialData.credentials_type = this.credentialsType; - } + if (this.manifests) { + initialData.manifests = this.manifests; + } + + const cloudStorageData = { + ...initialData, + ...prepareOptionalFields(this), + }; - if (this.manifests) { - initialData.manifests = this.manifests; + await serverProxy.cloudStorages.update(this.id, cloudStorageData); + return this; } + // create + const initialData: RawCloudStorageData = { + display_name: this.displayName, + credentials_type: this.credentialsType, + provider_type: this.providerType, + resource: this.resource, + manifests: this.manifests, + }; + const cloudStorageData = { ...initialData, ...prepareOptionalFields(this), }; - await serverProxy.cloudStorages.update(this.id, cloudStorageData); - return this; - } - - // create - const initialData = { - display_name: this.displayName, - credentials_type: this.credentialsType, - provider_type: this.providerType, - resource: this.resource, - manifests: this.manifests, - }; - - const cloudStorageData = { - ...initialData, - ...prepareOptionalFields(this), - }; - - const cloudStorage = await serverProxy.cloudStorages.create(cloudStorageData); - return new CloudStorage(cloudStorage); - }; + const cloudStorage = await serverProxy.cloudStorages.create(cloudStorageData); + return new CloudStorage(cloudStorage); + }, + }, +}); - CloudStorage.prototype.delete.implementation = async function () { - const result = await serverProxy.cloudStorages.delete(this.id); - return result; - }; - - CloudStorage.prototype.getContent.implementation = async function () { - const result = await serverProxy.cloudStorages.getContent(this.id, this.manifestPath); - return result; - }; +Object.defineProperties(CloudStorage.prototype.delete, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(): Promise { + const result = await serverProxy.cloudStorages.delete(this.id); + return result; + }, + }, +}); - CloudStorage.prototype.getPreview.implementation = async function getPreview() { - return new Promise((resolve, reject) => { - serverProxy.cloudStorages - .getPreview(this.id) - .then((result) => { - if (isNode) { - resolve(global.Buffer.from(result, 'binary').toString('base64')); - } else if (isBrowser) { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result); - }; - reader.readAsDataURL(result); - } - }) - .catch((error) => { - reject(error); - }); - }); - }; +Object.defineProperties(CloudStorage.prototype.getContent, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(): Promise { + const result = await serverProxy.cloudStorages.getContent(this.id, this.manifestPath); + return result; + }, + }, +}); - CloudStorage.prototype.getStatus.implementation = async function () { - const result = await serverProxy.cloudStorages.getStatus(this.id); - return result; - }; +Object.defineProperties(CloudStorage.prototype.getPreview, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(): Promise { + return new Promise((resolve, reject) => { + serverProxy.cloudStorages + .getPreview(this.id) + .then((result) => { + if (isNode) { + resolve(global.Buffer.from(result, 'binary').toString('base64')); + } else if (isBrowser) { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result); + }; + reader.readAsDataURL(result); + } + }) + .catch((error) => { + reject(error); + }); + }); + }, + }, +}); - module.exports = { - CloudStorage, - }; -})(); +Object.defineProperties(CloudStorage.prototype.getStatus, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(): Promise { + const result = await serverProxy.cloudStorages.getStatus(this.id); + return result; + }, + }, +}); \ No newline at end of file diff --git a/cvat-core/src/comment.ts b/cvat-core/src/comment.ts index 8703dd504897..347e23f81094 100644 --- a/cvat-core/src/comment.ts +++ b/cvat-core/src/comment.ts @@ -1,18 +1,33 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -const User = require('./user'); -const { ArgumentError } = require('./exceptions'); +import User from './user'; +import { ArgumentError } from './exceptions'; -/** - * Class representing a single comment - * @memberof module:API.cvat.classes - * @hideconstructor - */ -class Comment { - constructor(initialData) { - const data = { +export interface RawCommentData { + id?: number; + message?: string; + created_date?: string; + updated_date?: string; + owner?: any; +} + +interface SerializedCommentData extends RawCommentData{ + owner_id?: number; + issue?: number; +} + +export default class Comment { + public readonly id: number; + public readonly createdDate: string; + public readonly updatedDate: string; + public readonly owner: User; + public message: string; + + constructor(initialData: RawCommentData) { + const data: RawCommentData = { id: undefined, message: undefined, created_date: undefined, @@ -34,23 +49,9 @@ class Comment { Object.defineProperties( this, Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Comment - * @readonly - * @instance - */ id: { get: () => data.id, }, - /** - * @name message - * @type {string} - * @memberof module:API.cvat.classes.Comment - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ message: { get: () => data.message, set: (value) => { @@ -60,34 +61,12 @@ class Comment { data.message = value; }, }, - /** - * @name createdDate - * @type {string} - * @memberof module:API.cvat.classes.Comment - * @readonly - * @instance - */ createdDate: { get: () => data.created_date, }, - /** - * @name updatedDate - * @type {string} - * @memberof module:API.cvat.classes.Comment - * @readonly - * @instance - */ updatedDate: { get: () => data.updated_date, }, - /** - * Instance of a user who has created the comment - * @name owner - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Comment - * @readonly - * @instance - */ owner: { get: () => data.owner, }, @@ -98,8 +77,8 @@ class Comment { ); } - serialize() { - const data = { + public serialize(): SerializedCommentData { + const data: SerializedCommentData = { message: this.message, }; @@ -119,5 +98,3 @@ class Comment { return data; } } - -module.exports = Comment; diff --git a/cvat-core/src/common.ts b/cvat-core/src/common.ts index 36c7aaed09f1..10eed7cf0002 100644 --- a/cvat-core/src/common.ts +++ b/cvat-core/src/common.ts @@ -1,4 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -12,6 +13,10 @@ export function isInteger(value): boolean { return typeof value === 'number' && Number.isInteger(value); } +export function isEmail(value): boolean { + return typeof value === 'string' && RegExp(/^[^\s@]+@[^\s@]+\.[^\s@]+$/).test(value); +} + // Called with specific Enum context export function isEnum(value): boolean { for (const key in this) { @@ -60,7 +65,7 @@ export function checkExclusiveFields(obj, exclusive, ignore): void { } } -export function checkObjectType(name, value, type, instance): boolean { +export function checkObjectType(name, value, type, instance?): boolean { if (type) { if (typeof value !== type) { // specific case for integers which aren't native type in JS diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 8bee70bee5ef..a2a4647100e2 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -1,4 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier = MIT @@ -389,7 +390,23 @@ export enum CloudStorageCredentialsType { } /** - * Task statuses + * Types of cloud storage statuses + * @enum {string} + * @name CloudStorageStatus + * @memberof module:API.cvat.enums + * @property {string} AVAILABLE 'AVAILABLE' + * @property {string} NOT_FOUND 'NOT_FOUND' + * @property {string} FORBIDDEN 'FORBIDDEN' + * @readonly + */ +export enum CloudStorageStatus { + AVAILABLE = 'AVAILABLE', + NOT_FOUND = 'NOT_FOUND', + FORBIDDEN = 'FORBIDDEN', +} + +/** + * Membership roles * @enum {string} * @name MembershipRole * @memberof module:API.cvat.enums @@ -423,3 +440,43 @@ export enum SortingMethod { PREDEFINED = 'predefined', RANDOM = 'random', } + +/** + * Types of storage locations + * @enum {string} + * @name StorageLocation + * @memberof module:API.cvat.enums + * @property {string} LOCAL 'local' + * @property {string} CLOUD_STORAGE 'cloud_storage' + * @readonly +*/ +export enum StorageLocation { + LOCAL = 'local', + CLOUD_STORAGE = 'cloud_storage', +} + +/** + * Webhook source types + * @enum {string} + * @name WebhookSourceType + * @memberof module:API.cvat.enums + * @property {string} ORGANIZATION 'organization' + * @property {string} PROJECT 'project' + * @readonly +*/ +export enum WebhookSourceType { + ORGANIZATION = 'organization', + PROJECT = 'project', +} + +/** + * Webhook content types + * @enum {string} + * @name WebhookContentType + * @memberof module:API.cvat.enums + * @property {string} JSON 'json' + * @readonly +*/ +export enum WebhookContentType { + JSON = 'application/json', +} diff --git a/cvat-core/src/exceptions.ts b/cvat-core/src/exceptions.ts index db30611580c6..88eaee74e910 100644 --- a/cvat-core/src/exceptions.ts +++ b/cvat-core/src/exceptions.ts @@ -171,7 +171,7 @@ export class Exception extends Error { }; try { - const serverProxy = require('./server-proxy'); + const serverProxy = require('./server-proxy').default; await serverProxy.server.exception(exceptionObject); } catch (exception) { // add event diff --git a/cvat-core/src/frames.ts b/cvat-core/src/frames.ts index 4dfd9ae02587..048c1fe29ba0 100644 --- a/cvat-core/src/frames.ts +++ b/cvat-core/src/frames.ts @@ -5,7 +5,7 @@ (() => { const cvatData = require('cvat-data'); const PluginRegistry = require('./plugins').default; - const serverProxy = require('./server-proxy'); + const serverProxy = require('./server-proxy').default; const { isBrowser, isNode } = require('browser-or-node'); const { Exception, ArgumentError, DataError } = require('./exceptions'); diff --git a/cvat-core/src/issue.ts b/cvat-core/src/issue.ts index 795d5b7a6b35..82e6969f74da 100644 --- a/cvat-core/src/issue.ts +++ b/cvat-core/src/issue.ts @@ -1,23 +1,40 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -const quickhull = require('quickhull'); - -const PluginRegistry = require('./plugins').default; -const Comment = require('./comment'); -const User = require('./user'); -const { ArgumentError } = require('./exceptions'); -const serverProxy = require('./server-proxy'); +import quickhull from 'quickhull'; + +import { Job } from 'session'; +import PluginRegistry from './plugins'; +import Comment, { RawCommentData } from './comment'; +import User from './user'; +import { ArgumentError } from './exceptions'; +import serverProxy from './server-proxy'; + +interface RawIssueData { + id?: number; + job?: any; + position?: number[]; + comments?: any; + frame?: number; + owner?: any; + resolved?: boolean; + created_date?: string; +} -/** - * Class representing a single issue - * @memberof module:API.cvat.classes - * @hideconstructor - */ -class Issue { - constructor(initialData) { - const data = { +export default class Issue { + public readonly id: number; + public readonly job: Job; + public readonly comments: Comment[]; + public readonly frame: number; + public readonly owner: User; + public readonly resolved: boolean; + public readonly createdDate: string; + public position: number[]; + + constructor(initialData: RawIssueData) { + const data: RawIssueData = { id: undefined, job: undefined, position: undefined, @@ -47,25 +64,9 @@ class Issue { Object.defineProperties( this, Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - */ id: { get: () => data.id, }, - /** - * Region of interests of the issue - * @name position - * @type {number[]} - * @memberof module:API.cvat.classes.Issue - * @instance - * @readonly - * @throws {module:API.cvat.exceptions.ArgumentError} - */ position: { get: () => data.position, set: (value) => { @@ -75,69 +76,21 @@ class Issue { data.position = value; }, }, - /** - * ID of a job, the issue is linked with - * @name job - * @type {number} - * @memberof module:API.cvat.classes.Issue - * @instance - * @readonly - * @throws {module:API.cvat.exceptions.ArgumentError} - */ job: { get: () => data.job, }, - /** - * List of comments attached to the issue - * @name comments - * @type {module:API.cvat.classes.Comment[]} - * @memberof module:API.cvat.classes.Issue - * @instance - * @readonly - * @throws {module:API.cvat.exceptions.ArgumentError} - */ comments: { get: () => [...data.comments], }, - /** - * @name frame - * @type {number} - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - */ frame: { get: () => data.frame, }, - /** - * @name createdDate - * @type {string} - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - */ createdDate: { get: () => data.created_date, }, - /** - * An instance of a user who has raised the issue - * @name owner - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - */ owner: { get: () => data.owner, }, - /** - * The flag defines issue status - * @name resolved - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - */ resolved: { get: () => data.resolved, }, @@ -148,7 +101,7 @@ class Issue { ); } - static hull(coordinates) { + public static hull(coordinates: number[]): number[] { if (coordinates.length > 4) { const points = coordinates.reduce((acc, coord, index, arr) => { if (index % 2) acc.push({ x: arr[index - 1], y: coord }); @@ -163,82 +116,36 @@ class Issue { return coordinates; } - /** - * @typedef {Object} CommentData - * @property {string} message a comment message - * @global - */ - /** - * Method appends a comment to the issue - * For a new issue it saves comment locally, for a saved issue it saves comment on the server - * @method comment - * @memberof module:API.cvat.classes.Issue - * @param {CommentData} data - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async comment(data) { + // Method appends a comment to the issue + // For a new issue it saves comment locally, for a saved issue it saves comment on the server + public async comment(data: RawCommentData): Promise { const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.comment, data); return result; } - /** - * The method resolves the issue - * New issues are resolved locally, server-saved issues are resolved on the server - * @method resolve - * @memberof module:API.cvat.classes.Issue - * @param {module:API.cvat.classes.User} user - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async resolve(user) { + // The method resolves the issue + // New issues are resolved locally, server-saved issues are resolved on the server + public async resolve(user: User): Promise { const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.resolve, user); return result; } - /** - * The method resolves the issue - * New issues are reopened locally, server-saved issues are reopened on the server - * @method reopen - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async reopen() { + // The method reopens the issue + // New issues are reopened locally, server-saved issues are reopened on the server + public async reopen(): Promise { const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.reopen); return result; } - /** - * The method deletes the issue - * Deletes local or server-saved issues - * @method delete - * @memberof module:API.cvat.classes.Issue - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async delete() { + // The method deletes the issue + // Deletes local or server-saved issues + public async delete(): Promise { await PluginRegistry.apiWrapper.call(this, Issue.prototype.delete); } - serialize() { + public serialize(): RawIssueData { const { comments } = this; - const data = { + const data: RawIssueData = { position: this.position, frame: this.frame, comments: comments.map((comment) => comment.serialize()), @@ -264,53 +171,76 @@ class Issue { } } -Issue.prototype.comment.implementation = async function (data) { - if (typeof data !== 'object' || data === null) { - throw new ArgumentError(`The argument "data" must be an object. Got "${data}"`); - } - if (typeof data.message !== 'string' || data.message.length < 1) { - throw new ArgumentError(`Comment message must be a not empty string. Got "${data.message}"`); - } - - const comment = new Comment(data); - if (typeof this.id === 'number') { - const serialized = comment.serialize(); - serialized.issue = this.id; - const response = await serverProxy.comments.create(serialized); - const savedComment = new Comment(response); - this.__internal.comments.push(savedComment); - } else { - this.__internal.comments.push(comment); - } -}; - -Issue.prototype.resolve.implementation = async function (user) { - if (!(user instanceof User)) { - throw new ArgumentError(`The argument "user" must be an instance of a User class. Got "${typeof user}"`); - } - - if (typeof this.id === 'number') { - const response = await serverProxy.issues.update(this.id, { resolved: true }); - this.__internal.resolved = response.resolved; - } else { - this.__internal.resolved = true; - } -}; - -Issue.prototype.reopen.implementation = async function () { - if (typeof this.id === 'number') { - const response = await serverProxy.issues.update(this.id, { resolved: false }); - this.__internal.resolved = response.resolved; - } else { - this.__internal.resolved = false; - } -}; +Object.defineProperties(Issue.prototype.comment, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(data: RawCommentData) { + if (typeof data !== 'object' || data === null) { + throw new ArgumentError(`The argument "data" must be an object. Got "${data}"`); + } + if (typeof data.message !== 'string' || data.message.length < 1) { + throw new ArgumentError(`Comment message must be a not empty string. Got "${data.message}"`); + } -Issue.prototype.delete.implementation = async function () { - const { id } = this; - if (id >= 0) { - await serverProxy.issues.delete(id); - } -}; + const comment = new Comment(data); + if (typeof this.id === 'number') { + const serialized = comment.serialize(); + serialized.issue = this.id; + const response = await serverProxy.comments.create(serialized); + const savedComment = new Comment(response); + this.__internal.comments.push(savedComment); + } else { + this.__internal.comments.push(comment); + } + }, + }, +}); + +Object.defineProperties(Issue.prototype.resolve, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(user: User) { + if (!(user instanceof User)) { + throw new ArgumentError(`The argument "user" must be an + instance of a User class. Got "${typeof user}"`); + } -module.exports = Issue; + if (typeof this.id === 'number') { + const response = await serverProxy.issues.update(this.id, { resolved: true }); + this.__internal.resolved = response.resolved; + } else { + this.__internal.resolved = true; + } + }, + }, +}); + +Object.defineProperties(Issue.prototype.reopen, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + if (typeof this.id === 'number') { + const response = await serverProxy.issues.update(this.id, { resolved: false }); + this.__internal.resolved = response.resolved; + } else { + this.__internal.resolved = false; + } + }, + }, +}); + +Object.defineProperties(Issue.prototype.delete, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + const { id } = this; + if (id >= 0) { + await serverProxy.issues.delete(id); + } + }, + }, +}); diff --git a/cvat-core/src/lambda-manager.ts b/cvat-core/src/lambda-manager.ts index b7562dabcad5..51a8571078c5 100644 --- a/cvat-core/src/lambda-manager.ts +++ b/cvat-core/src/lambda-manager.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -const serverProxy = require('./server-proxy'); +const serverProxy = require('./server-proxy').default; const { ArgumentError } = require('./exceptions'); const MLModel = require('./ml-model'); const { RQStatus } = require('./enums'); diff --git a/cvat-core/src/log.ts b/cvat-core/src/log.ts index ef06a3ac332f..40d7aea585fc 100644 --- a/cvat-core/src/log.ts +++ b/cvat-core/src/log.ts @@ -1,19 +1,23 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -const { detect } = require('detect-browser'); -const PluginRegistry = require('./plugins').default; -const { ArgumentError } = require('./exceptions'); -const { LogType } = require('./enums'); - -/** - * Class representing a single log - * @memberof module:API.cvat.classes - * @hideconstructor - */ -class Log { - constructor(logType, payload) { +import { detect } from 'detect-browser'; +import PluginRegistry from './plugins'; +import { LogType } from './enums'; +import { ArgumentError } from './exceptions'; + +export class Log { + public readonly id: number; + public readonly type: LogType; + public readonly time: Date; + + public payload: any; + + protected onCloseCallback: (() => void) | null; + + constructor(logType: LogType, payload: any) { this.onCloseCallback = null; this.type = logType; @@ -21,11 +25,11 @@ class Log { this.time = new Date(); } - onClose(callback) { + public onClose(callback: () => void): void { this.onCloseCallback = callback; } - validatePayload() { + public validatePayload(): void { if (typeof this.payload !== 'object') { throw new ArgumentError('Payload must be an object'); } @@ -38,7 +42,7 @@ class Log { } } - dump() { + public dump(): any { const payload = { ...this.payload }; const body = { name: this.type, @@ -58,38 +62,33 @@ class Log { }; } - /** - * Method saves a durable log in a storage
- * Note then you can call close() multiple times
- * Log duration will be computed based on the latest call
- * All payloads will be shallowly combined (all top level properties will exist) - * @method close - * @memberof module:API.cvat.classes.Log - * @param {object} [payload] part of payload can be added when close a log - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async close(payload = {}) { + // Method saves a durable log in a storage + // Note then you can call close() multiple times + // Log duration will be computed based on the latest call + // All payloads will be shallowly combined (all top level properties will exist) + public async close(payload = {}): Promise { const result = await PluginRegistry.apiWrapper.call(this, Log.prototype.close, payload); return result; } } -Log.prototype.close.implementation = function (payload) { - this.payload.duration = Date.now() - this.time.getTime(); - this.payload = { ...this.payload, ...payload }; - - if (this.onCloseCallback) { - this.onCloseCallback(); - } -}; +Object.defineProperties(Log.prototype.close, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(payload: any) { + this.payload.duration = Date.now() - this.time.getTime(); + this.payload = { ...this.payload, ...payload }; + if (this.onCloseCallback) { + this.onCloseCallback(); + } + }, + }, +}); class LogWithCount extends Log { - validatePayload() { - Log.prototype.validatePayload.call(this); + public validatePayload(): void { + super.validatePayload.call(this); if (!Number.isInteger(this.payload.count) || this.payload.count < 1) { const message = `The field "count" is required for "${this.type}" log. It must be a positive integer`; throw new ArgumentError(message); @@ -98,8 +97,8 @@ class LogWithCount extends Log { } class LogWithObjectsInfo extends Log { - validatePayload() { - const generateError = (name, range) => { + public validatePayload(): void { + const generateError = (name: string, range: string): void => { const message = `The field "${name}" is required for "${this.type}" log. ${range}`; throw new ArgumentError(message); }; @@ -139,14 +138,13 @@ class LogWithObjectsInfo extends Log { } class LogWithWorkingTime extends Log { - validatePayload() { - Log.prototype.validatePayload.call(this); + public validatePayload(): void { + super.validatePayload.call(this); if ( - !( - 'working_time' in this.payload) || - !typeof this.payload.working_time === 'number' || - this.payload.working_time < 0 + !('working_time' in this.payload) || + !(typeof this.payload.working_time === 'number') || + this.payload.working_time < 0 ) { const message = ` The field "working_time" is required for ${this.type} log. It must be a number not less than 0 @@ -157,8 +155,8 @@ class LogWithWorkingTime extends Log { } class LogWithExceptionInfo extends Log { - validatePayload() { - Log.prototype.validatePayload.call(this); + public validatePayload(): void { + super.validatePayload.call(this); if (typeof this.payload.message !== 'string') { const message = `The field "message" is required for ${this.type} log. It must be a string`; @@ -186,7 +184,7 @@ class LogWithExceptionInfo extends Log { } } - dump() { + public dump(): any { let body = super.dump(); const { payload } = body; const client = detect(); @@ -212,7 +210,7 @@ class LogWithExceptionInfo extends Log { } } -function logFactory(logType, payload) { +export default function logFactory(logType: LogType, payload: any): Log { const logsWithCount = [ LogType.deleteObject, LogType.mergeObjects, @@ -238,5 +236,3 @@ function logFactory(logType, payload) { return new Log(logType, payload); } - -module.exports = logFactory; diff --git a/cvat-core/src/logger-storage.ts b/cvat-core/src/logger-storage.ts index 530219f93d4a..c9a5593a94e4 100644 --- a/cvat-core/src/logger-storage.ts +++ b/cvat-core/src/logger-storage.ts @@ -1,12 +1,13 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -const PluginRegistry = require('./plugins').default; -const serverProxy = require('./server-proxy'); -const logFactory = require('./log'); -const { ArgumentError } = require('./exceptions'); -const { LogType } = require('./enums'); +import PluginRegistry from './plugins'; +import serverProxy from './server-proxy'; +import logFactory, { Log } from './log'; +import { LogType } from './enums'; +import { ArgumentError } from './exceptions'; const WORKING_TIME_THRESHOLD = 100000; // ms, 1.66 min @@ -16,36 +17,49 @@ function sleep(ms): Promise { }); } +interface IgnoreRule { + lastLog: Log | null; + timeThreshold?: number; + ignore: (previousLog: Log, currentPayload: any) => boolean; +} + class LoggerStorage { + public clientID: string; + public lastLogTime: number; + public workingTime: number; + public collection: Array; + public ignoreRules: Record; + public isActiveChecker: (() => boolean) | null; + public saving: boolean; + constructor() { this.clientID = Date.now().toString().substr(-6); this.lastLogTime = Date.now(); this.workingTime = 0; this.collection = []; - this.ignoreRules = {}; // by event this.isActiveChecker = null; this.saving = false; - - this.ignoreRules[LogType.zoomImage] = { - lastLog: null, - timeThreshold: 1000, - ignore(previousLog) { - return Date.now() - previousLog.time < this.timeThreshold; + this.ignoreRules = { + [LogType.zoomImage]: { + lastLog: null, + timeThreshold: 1000, + ignore(previousLog: Log) { + return (Date.now() - previousLog.time.getTime()) < this.timeThreshold; + }, }, - }; - - this.ignoreRules[LogType.changeAttribute] = { - lastLog: null, - ignore(previousLog, currentPayload) { - return ( - currentPayload.object_id === previousLog.payload.object_id && - currentPayload.id === previousLog.payload.id - ); + [LogType.changeAttribute]: { + lastLog: null, + ignore(previousLog: Log, currentPayload: any) { + return ( + currentPayload.object_id === previousLog.payload.object_id && + currentPayload.id === previousLog.payload.id + ); + }, }, }; } - updateWorkingTime() { + protected updateWorkingTime(): void { if (!this.isActiveChecker || this.isActiveChecker()) { const lastLogTime = Date.now(); const diff = lastLogTime - this.lastLogTime; @@ -54,7 +68,7 @@ class LoggerStorage { } } - async configure(isActiveChecker, activityHelper) { + public async configure(isActiveChecker, activityHelper): Promise { const result = await PluginRegistry.apiWrapper.call( this, LoggerStorage.prototype.configure, @@ -64,123 +78,143 @@ class LoggerStorage { return result; } - async log(logType, payload = {}, wait = false) { + public async log(logType: LogType, payload = {}, wait = false): Promise { const result = await PluginRegistry.apiWrapper.call(this, LoggerStorage.prototype.log, logType, payload, wait); return result; } - async save() { + public async save(): Promise { const result = await PluginRegistry.apiWrapper.call(this, LoggerStorage.prototype.save); return result; } } -LoggerStorage.prototype.configure.implementation = function (isActiveChecker, userActivityCallback) { - if (typeof isActiveChecker !== 'function') { - throw new ArgumentError('isActiveChecker argument must be callable'); - } - - if (!Array.isArray(userActivityCallback)) { - throw new ArgumentError('userActivityCallback argument must be an array'); - } - - this.isActiveChecker = () => !!isActiveChecker(); - userActivityCallback.push(this.updateWorkingTime.bind(this)); -}; - -LoggerStorage.prototype.log.implementation = function (logType, payload, wait) { - if (typeof payload !== 'object') { - throw new ArgumentError('Payload must be an object'); - } - - if (typeof wait !== 'boolean') { - throw new ArgumentError('Payload must be an object'); - } - - if (logType in this.ignoreRules) { - const ignoreRule = this.ignoreRules[logType]; - const { lastLog } = ignoreRule; - if (lastLog && ignoreRule.ignore(lastLog, payload)) { - lastLog.payload = { - ...lastLog.payload, - ...payload, +Object.defineProperties(LoggerStorage.prototype.configure, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(isActiveChecker: () => boolean, userActivityCallback: Array) { + if (typeof isActiveChecker !== 'function') { + throw new ArgumentError('isActiveChecker argument must be callable'); + } + + if (!Array.isArray(userActivityCallback)) { + throw new ArgumentError('userActivityCallback argument must be an array'); + } + + this.isActiveChecker = () => !!isActiveChecker(); + userActivityCallback.push(this.updateWorkingTime.bind(this)); + }, + }, +}); + +Object.defineProperties(LoggerStorage.prototype.log, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(logType: LogType, payload: any, wait: boolean) { + if (typeof payload !== 'object') { + throw new ArgumentError('Payload must be an object'); + } + + if (typeof wait !== 'boolean') { + throw new ArgumentError('Wait must be boolean'); + } + + if (logType in this.ignoreRules) { + const ignoreRule = this.ignoreRules[logType]; + const { lastLog } = ignoreRule; + if (lastLog && ignoreRule.ignore(lastLog, payload)) { + lastLog.payload = { + ...lastLog.payload, + ...payload, + }; + + this.updateWorkingTime(); + return ignoreRule.lastLog; + } + } + + const logPayload = { ...payload }; + logPayload.client_id = this.clientID; + if (this.isActiveChecker) { + logPayload.is_active = this.isActiveChecker(); + } + + const log = logFactory(logType, { ...logPayload }); + if (logType in this.ignoreRules) { + this.ignoreRules[logType].lastLog = log; + } + + const pushEvent = (): void => { + this.updateWorkingTime(); + log.validatePayload(); + log.onClose(null); + this.collection.push(log); }; - this.updateWorkingTime(); - return ignoreRule.lastLog; - } - } - - const logPayload = { ...payload }; - logPayload.client_id = this.clientID; - if (this.isActiveChecker) { - logPayload.is_active = this.isActiveChecker(); - } - - const log = logFactory(logType, { ...logPayload }); - if (logType in this.ignoreRules) { - this.ignoreRules[logType].lastLog = log; - } - - const pushEvent = () => { - this.updateWorkingTime(); - log.validatePayload(); - log.onClose(null); - this.collection.push(log); - }; - - if (log.type === LogType.sendException) { - serverProxy.server.exception(log.dump()).catch(() => { - pushEvent(); - }); - - return log; - } - - if (wait) { - log.onClose(pushEvent); - } else { - pushEvent(); - } - - return log; -}; - -LoggerStorage.prototype.save.implementation = async function () { - while (this.saving) { - await sleep(100); - } - - const collectionToSend = [...this.collection]; - const lastLog = this.collection[this.collection.length - 1]; - - const logPayload = {}; - logPayload.client_id = this.clientID; - logPayload.working_time = this.workingTime; - if (this.isActiveChecker) { - logPayload.is_active = this.isActiveChecker(); - } - - if (lastLog && lastLog.type === LogType.sendTaskInfo) { - logPayload.job_id = lastLog.payload.job_id; - logPayload.task_id = lastLog.payload.task_id; - } - - const userActivityLog = logFactory(LogType.sendUserActivity, logPayload); - collectionToSend.push(userActivityLog); - - try { - this.saving = true; - await serverProxy.logs.save(collectionToSend.map((log) => log.dump())); - for (const rule of Object.values(this.ignoreRules)) { - rule.lastLog = null; - } - this.collection = []; - this.workingTime = 0; - this.lastLogTime = Date.now(); - } finally { - this.saving = false; - } -}; + if (log.type === LogType.sendException) { + serverProxy.server.exception(log.dump()).catch(() => { + pushEvent(); + }); + + return log; + } + + if (wait) { + log.onClose(pushEvent); + } else { + pushEvent(); + } + + return log; + }, + }, +}); + +Object.defineProperties(LoggerStorage.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + while (this.saving) { + await sleep(100); + } + + const collectionToSend = [...this.collection]; + const lastLog = this.collection[this.collection.length - 1]; + + const logPayload: any = { + client_id: this.clientID, + working_time: this.workingTime, + }; -module.exports = new LoggerStorage(); + if (this.isActiveChecker) { + logPayload.is_active = this.isActiveChecker(); + } + + if (lastLog && lastLog.type === LogType.sendTaskInfo) { + logPayload.job_id = lastLog.payload.job_id; + logPayload.task_id = lastLog.payload.task_id; + } + + const userActivityLog = logFactory(LogType.sendUserActivity, logPayload); + collectionToSend.push(userActivityLog); + + try { + this.saving = true; + await serverProxy.logs.save(collectionToSend.map((log) => log.dump())); + for (const rule of Object.values(this.ignoreRules)) { + rule.lastLog = null; + } + this.collection = []; + this.workingTime = 0; + this.lastLogTime = Date.now(); + } finally { + this.saving = false; + } + }, + }, +}); + +export default new LoggerStorage(); diff --git a/cvat-core/src/organization.ts b/cvat-core/src/organization.ts index 4fcf129beff5..84856bf096ed 100644 --- a/cvat-core/src/organization.ts +++ b/cvat-core/src/organization.ts @@ -1,35 +1,56 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -const { checkObjectType, isEnum } = require('./common'); -const config = require('./config'); -const { MembershipRole } = require('./enums'); -const { ArgumentError, ServerError } = require('./exceptions'); -const PluginRegistry = require('./plugins').default; -const serverProxy = require('./server-proxy'); -const User = require('./user'); - -/** - * Class representing an organization - * @memberof module:API.cvat.classes - */ -class Organization { - /** - * @param {object} initialData - Object which is used for initialization - *
It must contains keys: - *
  • slug - - *
    It can contains keys: - *
  • name - *
  • description - *
  • owner - *
  • created_date - *
  • updated_date - *
  • contact - */ - constructor(initialData) { - const data = { +import { checkObjectType, isEnum } from './common'; +import config from './config'; +import { MembershipRole } from './enums'; +import { ArgumentError, ServerError } from './exceptions'; +import PluginRegistry from './plugins'; +import serverProxy from './server-proxy'; +import User from './user'; + +interface RawOrganizationData { + id?: number, + slug?: string, + name?: string, + description?: string, + created_date?: string, + updated_date?: string, + owner?: any, + contact?: OrganizationContact, +} + +interface OrganizationContact { + email?: string; + location?: string; + phoneNumber?: string +} + +interface Membership { + user: User; + is_active: boolean; + joined_date: string; + role: MembershipRole; + invitation: { + created_date: string; + owner: User; + } | null; +} + +export default class Organization { + public readonly id: number; + public readonly slug: string; + public readonly createdDate: string; + public readonly updatedDate: string; + public readonly owner: User; + public contact: OrganizationContact; + public name: string; + public description: string; + + constructor(initialData: RawOrganizationData) { + const data: RawOrganizationData = { id: undefined, slug: undefined, name: undefined, @@ -65,7 +86,9 @@ class Organization { checkObjectType('contact', data.contact, 'object'); for (const prop in data.contact) { if (typeof data.contact[prop] !== 'string') { - throw ArgumentError(`Contact fields must be strings, tried to set ${typeof data.contact[prop]}`); + throw new ArgumentError( + `Contact fields must be strings,tried to set ${typeof data.contact[prop]}`, + ); } } } @@ -85,7 +108,7 @@ class Organization { get: () => data.name, set: (name) => { if (typeof name !== 'string') { - throw ArgumentError(`Name property must be a string, tried to set ${typeof description}`); + throw new ArgumentError(`Name property must be a string, tried to set ${typeof name}`); } data.name = name; }, @@ -94,7 +117,7 @@ class Organization { get: () => data.description, set: (description) => { if (typeof description !== 'string') { - throw ArgumentError( + throw new ArgumentError( `Description property must be a string, tried to set ${typeof description}`, ); } @@ -105,11 +128,13 @@ class Organization { get: () => ({ ...data.contact }), set: (contact) => { if (typeof contact !== 'object') { - throw ArgumentError(`Contact property must be an object, tried to set ${typeof contact}`); + throw new ArgumentError(`Contact property must be an object, tried to set ${typeof contact}`); } for (const prop in contact) { if (typeof contact[prop] !== 'string') { - throw ArgumentError(`Contact fields must be strings, tried to set ${typeof contact[prop]}`); + throw new ArgumentError( + `Contact fields must be strings, tried to set ${typeof contact[prop]}`, + ); } } data.contact = { ...contact }; @@ -127,37 +152,14 @@ class Organization { }); } - /** - * Method updates organization data if it was created before, or creates a new organization - * @method save - * @returns {module:API.cvat.classes.Organization} - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async save() { + // Method updates organization data if it was created before, or creates a new organization + public async save(): Promise { const result = await PluginRegistry.apiWrapper.call(this, Organization.prototype.save); return result; } - /** - * Method returns paginatable list of organization members - * @method save - * @returns {module:API.cvat.classes.Organization} - * @param page page number - * @param page_size number of results per page - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async members(page = 1, page_size = 10) { + // Method returns paginatable list of organization members + public async members(page = 1, page_size = 10): Promise { const result = await PluginRegistry.apiWrapper.call( this, Organization.prototype.members, @@ -168,75 +170,27 @@ class Organization { return result; } - /** - * Method removes the organization - * @method remove - * @returns {module:API.cvat.classes.Organization} - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async remove() { + // Method removes the organization + public async remove(): Promise { const result = await PluginRegistry.apiWrapper.call(this, Organization.prototype.remove); return result; } - /** - * Method invites new members by email - * @method invite - * @returns {module:API.cvat.classes.Organization} - * @param {string} email - * @param {string} role - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async invite(email, role) { + // Method invites new members by email + public async invite(email: string, role: MembershipRole): Promise { const result = await PluginRegistry.apiWrapper.call(this, Organization.prototype.invite, email, role); return result; } - /** - * Method allows a user to get out from an organization - * The difference between deleteMembership is that membershipId is unknown in this case - * @method leave - * @returns {module:API.cvat.classes.Organization} - * @memberof module:API.cvat.classes.Organization - * @param {module:API.cvat.classes.User} user - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async leave(user) { + // Method allows a user to get out from an organization + // The difference between deleteMembership is that membershipId is unknown in this case + public async leave(user: User): Promise { const result = await PluginRegistry.apiWrapper.call(this, Organization.prototype.leave, user); return result; } - /** - * Method allows to change a membership role - * @method updateMembership - * @returns {module:API.cvat.classes.Organization} - * @param {number} membershipId - * @param {string} role - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async updateMembership(membershipId, role) { + // Method allows to change a membership role + public async updateMembership(membershipId: number, role: MembershipRole): Promise { const result = await PluginRegistry.apiWrapper.call( this, Organization.prototype.updateMembership, @@ -246,20 +200,8 @@ class Organization { return result; } - /** - * Method allows to kick a user from an organization - * @method deleteMembership - * @returns {module:API.cvat.classes.Organization} - * @param {number} membershipId - * @memberof module:API.cvat.classes.Organization - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async deleteMembership(membershipId) { + // Method allows to kick a user from an organization + public async deleteMembership(membershipId: number): Promise { const result = await PluginRegistry.apiWrapper.call( this, Organization.prototype.deleteMembership, @@ -269,110 +211,152 @@ class Organization { } } -Organization.prototype.save.implementation = async function () { - if (typeof this.id === 'number') { - const organizationData = { - name: this.name || this.slug, - description: this.description, - contact: this.contact, - }; - - const result = await serverProxy.organizations.update(this.id, organizationData); - return new Organization(result); - } - - const organizationData = { - slug: this.slug, - name: this.name || this.slug, - description: this.description, - contact: this.contact, - }; - - const result = await serverProxy.organizations.create(organizationData); - return new Organization(result); -}; - -Organization.prototype.members.implementation = async function (orgSlug, page, pageSize) { - checkObjectType('orgSlug', orgSlug, 'string'); - checkObjectType('page', page, 'number'); - checkObjectType('pageSize', pageSize, 'number'); - - const result = await serverProxy.organizations.members(orgSlug, page, pageSize); - await Promise.all( - result.results.map((membership) => { - const { invitation } = membership; - membership.user = new User(membership.user); - if (invitation) { - return serverProxy.organizations - .invitation(invitation) - .then((invitationData) => { - membership.invitation = invitationData; - }) - .catch(() => { - membership.invitation = null; - }); +Object.defineProperties(Organization.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + if (typeof this.id === 'number') { + const organizationData = { + name: this.name || this.slug, + description: this.description, + contact: this.contact, + }; + + const result = await serverProxy.organizations.update(this.id, organizationData); + return new Organization(result); } - return Promise.resolve(); - }), - ); - - result.results.count = result.count; - return result.results; -}; - -Organization.prototype.remove.implementation = async function () { - if (typeof this.id === 'number') { - await serverProxy.organizations.delete(this.id); - config.organizationID = null; - } -}; - -Organization.prototype.invite.implementation = async function (email, role) { - checkObjectType('email', email, 'string'); - if (!isEnum.bind(MembershipRole)(role)) { - throw new ArgumentError(`Role must be one of: ${Object.values(MembershipRole).toString()}`); - } - - if (typeof this.id === 'number') { - await serverProxy.organizations.invite(this.id, { email, role }); - } -}; - -Organization.prototype.updateMembership.implementation = async function (membershipId, role) { - checkObjectType('membershipId', membershipId, 'number'); - if (!isEnum.bind(MembershipRole)(role)) { - throw new ArgumentError(`Role must be one of: ${Object.values(MembershipRole).toString()}`); - } + const organizationData = { + slug: this.slug, + name: this.name || this.slug, + description: this.description, + contact: this.contact, + }; + + const result = await serverProxy.organizations.create(organizationData); + return new Organization(result); + }, + }, +}); + +Object.defineProperties(Organization.prototype.members, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(orgSlug: string, page: number, pageSize: number) { + checkObjectType('orgSlug', orgSlug, 'string'); + checkObjectType('page', page, 'number'); + checkObjectType('pageSize', pageSize, 'number'); + + const result = await serverProxy.organizations.members(orgSlug, page, pageSize); + await Promise.all( + result.results.map((membership) => { + const { invitation } = membership; + membership.user = new User(membership.user); + if (invitation) { + return serverProxy.organizations + .invitation(invitation) + .then((invitationData) => { + membership.invitation = invitationData; + }) + .catch(() => { + membership.invitation = null; + }); + } - if (typeof this.id === 'number') { - await serverProxy.organizations.updateMembership(membershipId, { role }); - } -}; + return Promise.resolve(); + }), + ); + + result.results.count = result.count; + return result.results; + }, + }, +}); + +Object.defineProperties(Organization.prototype.remove, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + if (typeof this.id === 'number') { + await serverProxy.organizations.delete(this.id); + config.organizationID = null; + } + }, + }, +}); + +Object.defineProperties(Organization.prototype.invite, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(email: string, role: MembershipRole) { + checkObjectType('email', email, 'string'); + if (!isEnum.bind(MembershipRole)(role)) { + throw new ArgumentError(`Role must be one of: ${Object.values(MembershipRole).toString()}`); + } -Organization.prototype.deleteMembership.implementation = async function (membershipId) { - checkObjectType('membershipId', membershipId, 'number'); - if (typeof this.id === 'number') { - await serverProxy.organizations.deleteMembership(membershipId); - } -}; - -Organization.prototype.leave.implementation = async function (user) { - checkObjectType('user', user, null, User); - if (typeof this.id === 'number') { - const result = await serverProxy.organizations.members(this.slug, 1, 10, { - filter: JSON.stringify({ - and: [{ - '==': [{ var: 'user' }, user.id], - }], - }), - }); - const [membership] = result.results; - if (!membership) { - throw new ServerError(`Could not find membership for user ${user.username} in organization ${this.slug}`); - } - await serverProxy.organizations.deleteMembership(membership.id); - } -}; + if (typeof this.id === 'number') { + await serverProxy.organizations.invite(this.id, { email, role }); + } + }, + }, +}); + +Object.defineProperties(Organization.prototype.updateMembership, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(membershipId: number, role: MembershipRole) { + checkObjectType('membershipId', membershipId, 'number'); + if (!isEnum.bind(MembershipRole)(role)) { + throw new ArgumentError(`Role must be one of: ${Object.values(MembershipRole).toString()}`); + } -module.exports = Organization; + if (typeof this.id === 'number') { + await serverProxy.organizations.updateMembership(membershipId, { role }); + } + }, + }, +}); + +Object.defineProperties(Organization.prototype.deleteMembership, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(membershipId: number) { + checkObjectType('membershipId', membershipId, 'number'); + if (typeof this.id === 'number') { + await serverProxy.organizations.deleteMembership(membershipId); + } + }, + }, +}); + +Object.defineProperties(Organization.prototype.leave, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(user: User) { + checkObjectType('user', user, null, User); + if (typeof this.id === 'number') { + const result = await serverProxy.organizations.members(this.slug, 1, 10, { + filter: JSON.stringify({ + and: [{ + '==': [{ var: 'user' }, user.id], + }], + }), + }); + const [membership] = result.results; + if (!membership) { + throw new ServerError( + `Could not find membership for user ${user.username} in organization ${this.slug}`, + ); + } + await serverProxy.organizations.deleteMembership(membership.id); + } + }, + }, +}); diff --git a/cvat-core/src/project-implementation.ts b/cvat-core/src/project-implementation.ts index fb6445dc88e9..efef65a74f33 100644 --- a/cvat-core/src/project-implementation.ts +++ b/cvat-core/src/project-implementation.ts @@ -1,93 +1,108 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const serverProxy = require('./server-proxy'); - const { getPreview } = require('./frames'); - - const { Project } = require('./project'); - const { exportDataset, importDataset } = require('./annotations'); - - function implementProject(projectClass) { - projectClass.prototype.save.implementation = async function () { - if (typeof this.id !== 'undefined') { - const projectData = this._updateTrigger.getUpdated(this, { - bugTracker: 'bug_tracker', - trainingProject: 'training_project', - assignee: 'assignee_id', - }); - if (projectData.assignee_id) { - projectData.assignee_id = projectData.assignee_id.id; - } - if (projectData.labels) { - projectData.labels = projectData.labels.map((el) => el.toJSON()); - } - - await serverProxy.projects.save(this.id, projectData); - this._updateTrigger.reset(); - return this; - } +import { Storage } from './storage'; - // initial creating - const projectSpec = { - name: this.name, - labels: this.labels.map((el) => el.toJSON()), - }; +const serverProxy = require('./server-proxy').default; +const { getPreview } = require('./frames'); - if (this.bugTracker) { - projectSpec.bug_tracker = this.bugTracker; - } +const Project = require('./project').default; +const { exportDataset, importDataset } = require('./annotations'); - if (this.trainingProject) { - projectSpec.training_project = this.trainingProject; +export default function implementProject(projectClass) { + projectClass.prototype.save.implementation = async function () { + if (typeof this.id !== 'undefined') { + const projectData = this._updateTrigger.getUpdated(this, { + bugTracker: 'bug_tracker', + trainingProject: 'training_project', + assignee: 'assignee_id', + }); + if (projectData.assignee_id) { + projectData.assignee_id = projectData.assignee_id.id; + } + if (projectData.labels) { + projectData.labels = projectData.labels.map((el) => el.toJSON()); } - const project = await serverProxy.projects.create(projectSpec); - return new Project(project); - }; + await serverProxy.projects.save(this.id, projectData); + this._updateTrigger.reset(); + return this; + } - projectClass.prototype.delete.implementation = async function () { - const result = await serverProxy.projects.delete(this.id); - return result; + // initial creating + const projectSpec: any = { + name: this.name, + labels: this.labels.map((el) => el.toJSON()), }; - projectClass.prototype.preview.implementation = async function () { - if (!this._internalData.task_ids.length) { - return ''; - } - const frameData = await getPreview(this._internalData.task_ids[0]); - return frameData; - }; + if (this.bugTracker) { + projectSpec.bug_tracker = this.bugTracker; + } - projectClass.prototype.annotations.exportDataset.implementation = async function ( - format, - saveImages, - customName, - ) { - const result = exportDataset(this, format, customName, saveImages); - return result; - }; - projectClass.prototype.annotations.importDataset.implementation = async function ( - format, - file, - updateStatusCallback, - ) { - return importDataset(this, format, file, updateStatusCallback); - }; + if (this.trainingProject) { + projectSpec.training_project = this.trainingProject; + } - projectClass.prototype.backup.implementation = async function () { - const result = await serverProxy.projects.backupProject(this.id); - return result; - }; + if (this.targetStorage) { + projectSpec.target_storage = this.targetStorage.toJSON(); + } - projectClass.restore.implementation = async function (file) { - const result = await serverProxy.projects.restoreProject(file); - return result.id; - }; + if (this.sourceStorage) { + projectSpec.source_storage = this.sourceStorage.toJSON(); + } + + const project = await serverProxy.projects.create(projectSpec); + return new Project(project); + }; + + projectClass.prototype.delete.implementation = async function () { + const result = await serverProxy.projects.delete(this.id); + return result; + }; + + projectClass.prototype.preview.implementation = async function () { + if (!this._internalData.task_ids.length) { + return ''; + } + const frameData = await getPreview(this._internalData.task_ids[0]); + return frameData; + }; + + projectClass.prototype.annotations.exportDataset.implementation = async function ( + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + customName?: string, + ) { + const result = exportDataset(this, format, saveImages, useDefaultSettings, targetStorage, customName); + return result; + }; + projectClass.prototype.annotations.importDataset.implementation = async function ( + format: string, + useDefaultSettings: boolean, + sourceStorage: Storage, + file: File | string, + updateStatusCallback, + ) { + return importDataset(this, format, useDefaultSettings, sourceStorage, file, updateStatusCallback); + }; + + projectClass.prototype.backup.implementation = async function ( + targetStorage: Storage, + useDefaultSettings: boolean, + fileName?: string, + ) { + const result = await serverProxy.projects.backup(this.id, targetStorage, useDefaultSettings, fileName); + return result; + }; - return projectClass; - } + projectClass.restore.implementation = async function (storage: Storage, file: File | string) { + const result = await serverProxy.projects.restore(storage, file); + return result; + }; - module.exports = implementProject; -})(); + return projectClass; +} diff --git a/cvat-core/src/project.ts b/cvat-core/src/project.ts index 01e63e3a0c42..f50ddbe8e6ff 100644 --- a/cvat-core/src/project.ts +++ b/cvat-core/src/project.ts @@ -1,374 +1,429 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const PluginRegistry = require('./plugins').default; - const { ArgumentError } = require('./exceptions'); - const { Label } = require('./labels'); - const User = require('./user'); - const { FieldUpdateTrigger } = require('./common'); +import { StorageLocation } from './enums'; +import { Storage } from './storage'; +const PluginRegistry = require('./plugins').default; +const { ArgumentError } = require('./exceptions'); +const { Label } = require('./labels'); +const User = require('./user').default; +const { FieldUpdateTrigger } = require('./common'); + +/** + * Class representing a project + * @memberof module:API.cvat.classes + */ +export default class Project { /** - * Class representing a project - * @memberof module:API.cvat.classes + * In a fact you need use the constructor only if you want to create a project + * @param {object} initialData - Object which is used for initialization + *
    It can contain keys: + *
  • name + *
  • labels */ - class Project { - /** - * In a fact you need use the constructor only if you want to create a project - * @param {object} initialData - Object which is used for initialization - *
    It can contain keys: - *
  • name - *
  • labels - */ - constructor(initialData) { - const data = { - id: undefined, - name: undefined, - status: undefined, - assignee: undefined, - owner: undefined, - bug_tracker: undefined, - created_date: undefined, - updated_date: undefined, - task_subsets: undefined, - training_project: undefined, - task_ids: undefined, - dimension: undefined, - }; + constructor(initialData) { + const data = { + id: undefined, + name: undefined, + status: undefined, + assignee: undefined, + owner: undefined, + bug_tracker: undefined, + created_date: undefined, + updated_date: undefined, + task_subsets: undefined, + training_project: undefined, + task_ids: undefined, + dimension: undefined, + source_storage: undefined, + target_storage: undefined, + labels: undefined, + }; - const updateTrigger = new FieldUpdateTrigger(); + const updateTrigger = new FieldUpdateTrigger(); - for (const property in data) { - if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { - data[property] = initialData[property]; - } + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; } + } - data.labels = []; + data.labels = []; - if (Array.isArray(initialData.labels)) { - data.labels = initialData.labels - .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); - } + if (Array.isArray(initialData.labels)) { + data.labels = initialData.labels + .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); + } - if (typeof initialData.training_project === 'object') { - data.training_project = { ...initialData.training_project }; - } + if (typeof initialData.training_project === 'object') { + data.training_project = { ...initialData.training_project }; + } - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - id: { - get: () => data.id, - }, - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Project - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - name: { - get: () => data.name, - set: (value) => { - if (!value.trim().length) { - throw new ArgumentError('Value must not be empty'); - } - data.name = value; - updateTrigger.update('name'); - }, + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + name: { + get: () => data.name, + set: (value) => { + if (!value.trim().length) { + throw new ArgumentError('Value must not be empty'); + } + data.name = value; + updateTrigger.update('name'); }, + }, - /** - * @name status - * @type {module:API.cvat.enums.TaskStatus} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - status: { - get: () => data.status, - }, - /** - * Instance of a user who was assigned for the project - * @name assignee - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - assignee: { - get: () => data.assignee, - set: (assignee) => { - if (assignee !== null && !(assignee instanceof User)) { - throw new ArgumentError('Value must be a user instance'); - } - data.assignee = assignee; - updateTrigger.update('assignee'); - }, - }, - /** - * Instance of a user who has created the project - * @name owner - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - owner: { - get: () => data.owner, - }, - /** - * @name bugTracker - * @type {string} - * @memberof module:API.cvat.classes.Project - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - bugTracker: { - get: () => data.bug_tracker, - set: (tracker) => { - data.bug_tracker = tracker; - updateTrigger.update('bugTracker'); - }, - }, - /** - * @name createdDate - * @type {string} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - createdDate: { - get: () => data.created_date, - }, - /** - * @name updatedDate - * @type {string} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - updatedDate: { - get: () => data.updated_date, + /** + * @name status + * @type {module:API.cvat.enums.TaskStatus} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + status: { + get: () => data.status, + }, + /** + * Instance of a user who was assigned for the project + * @name assignee + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + assignee: { + get: () => data.assignee, + set: (assignee) => { + if (assignee !== null && !(assignee instanceof User)) { + throw new ArgumentError('Value must be a user instance'); + } + data.assignee = assignee; + updateTrigger.update('assignee'); }, - /** - * Dimesion of the tasks in the project, if no task dimension is null - * @name dimension - * @type {string} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - dimension: { - get: () => data.dimension, + }, + /** + * Instance of a user who has created the project + * @name owner + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + owner: { + get: () => data.owner, + }, + /** + * @name bugTracker + * @type {string} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + bugTracker: { + get: () => data.bug_tracker, + set: (tracker) => { + data.bug_tracker = tracker; + updateTrigger.update('bugTracker'); }, - /** - * After project has been created value can be appended only. - * @name labels - * @type {module:API.cvat.classes.Label[]} - * @memberof module:API.cvat.classes.Project - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - labels: { - get: () => [...data.labels], - set: (labels) => { - if (!Array.isArray(labels)) { - throw new ArgumentError('Value must be an array of Labels'); - } + }, + /** + * @name createdDate + * @type {string} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + createdDate: { + get: () => data.created_date, + }, + /** + * @name updatedDate + * @type {string} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + updatedDate: { + get: () => data.updated_date, + }, + /** + * Dimesion of the tasks in the project, if no task dimension is null + * @name dimension + * @type {string} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + dimension: { + get: () => data.dimension, + }, + /** + * After project has been created value can be appended only. + * @name labels + * @type {module:API.cvat.classes.Label[]} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + labels: { + get: () => [...data.labels], + set: (labels) => { + if (!Array.isArray(labels)) { + throw new ArgumentError('Value must be an array of Labels'); + } - if (!Array.isArray(labels) || labels.some((label) => !(label instanceof Label))) { - throw new ArgumentError( - `Each array value must be an instance of Label. ${typeof label} was found`, - ); - } + if (!Array.isArray(labels) || labels.some((label) => !(label instanceof Label))) { + throw new ArgumentError( + `Each array value must be an instance of Label. ${typeof label} was found`, + ); + } - const IDs = labels.map((_label) => _label.id); - const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); - deletedLabels.forEach((_label) => { - _label.deleted = true; - }); + const IDs = labels.map((_label) => _label.id); + const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); + deletedLabels.forEach((_label) => { + _label.deleted = true; + }); - data.labels = [...deletedLabels, ...labels]; - updateTrigger.update('labels'); - }, - }, - /** - * Subsets array for related tasks - * @name subsets - * @type {string[]} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - subsets: { - get: () => [...data.task_subsets], + data.labels = [...deletedLabels, ...labels]; + updateTrigger.update('labels'); }, - /** - * Training project associated with this annotation project - * This is a simple object which contains - * keys like host, username, password, enabled, project_class - * @name trainingProject - * @type {object} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - */ - trainingProject: { - get: () => { - if (typeof data.training_project === 'object') { - return { ...data.training_project }; - } - return data.training_project; - }, - set: (updatedProject) => { - if (typeof training === 'object') { - data.training_project = { ...updatedProject }; - } else { - data.training_project = updatedProject; - } - updateTrigger.update('trainingProject'); - }, - }, - _internalData: { - get: () => data, + }, + /** + * Subsets array for related tasks + * @name subsets + * @type {string[]} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + subsets: { + get: () => [...data.task_subsets], + }, + /** + * Training project associated with this annotation project + * This is a simple object which contains + * keys like host, username, password, enabled, project_class + * @name trainingProject + * @type {object} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + trainingProject: { + get: () => { + if (typeof data.training_project === 'object') { + return { ...data.training_project }; + } + return data.training_project; }, - _updateTrigger: { - get: () => updateTrigger, + set: (updatedProject) => { + if (typeof training === 'object') { + data.training_project = { ...updatedProject }; + } else { + data.training_project = updatedProject; + } + updateTrigger.update('trainingProject'); }, - }), - ); + }, + /** + * Source storage for import resources. + * @name sourceStorage + * @type {module:API.cvat.classes.Storage} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + sourceStorage: { + get: () => ( + new Storage({ + location: data.source_storage?.location || StorageLocation.LOCAL, + cloudStorageId: data.source_storage?.cloud_storage_id, + }) + ), + }, + /** + * Target storage for export resources. + * @name targetStorage + * @type {module:API.cvat.classes.Storage} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + targetStorage: { + get: () => ( + new Storage({ + location: data.target_storage?.location || StorageLocation.LOCAL, + cloudStorageId: data.target_storage?.cloud_storage_id, + }) + ), + }, + _internalData: { + get: () => data, + }, + _updateTrigger: { + get: () => updateTrigger, + }, + }), + ); - // When we call a function, for example: project.annotations.get() - // In the method get we lose the project context - // So, we need return it - this.annotations = { - exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), - importDataset: Object.getPrototypeOf(this).annotations.importDataset.bind(this), - }; - } + // When we call a function, for example: project.annotations.get() + // In the method get we lose the project context + // So, we need return it + this.annotations = { + exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), + importDataset: Object.getPrototypeOf(this).annotations.importDataset.bind(this), + }; + } - /** - * Get the first frame of the first task of a project for preview - * @method preview - * @memberof Project - * @returns {string} - jpeg encoded image - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - async preview() { - const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.preview); - return result; - } + /** + * Get the first frame of the first task of a project for preview + * @method preview + * @memberof Project + * @returns {string} - jpeg encoded image + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async preview() { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.preview); + return result; + } - /** - * Method updates data of a created project or creates new project from scratch - * @method save - * @returns {module:API.cvat.classes.Project} - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async save() { - const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.save); - return result; - } + /** + * Method updates data of a created project or creates new project from scratch + * @method save + * @returns {module:API.cvat.classes.Project} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async save() { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.save); + return result; + } - /** - * Method deletes a project from a server - * @method delete - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - */ - async delete() { - const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.delete); - return result; - } + /** + * Method deletes a project from a server + * @method delete + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async delete() { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.delete); + return result; + } - /** - * Method makes a backup of a project - * @method export - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @returns {string} URL to get result archive - */ - async backup() { - const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.backup); - return result; - } + /** + * Method makes a backup of a project + * @method backup + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + * @returns {string} URL to get result archive + */ + async backup(targetStorage: Storage, useDefaultSettings: boolean, fileName?: string) { + const result = await PluginRegistry.apiWrapper.call( + this, + Project.prototype.backup, + targetStorage, + useDefaultSettings, + fileName, + ); + return result; + } - /** - * Method restores a project from a backup - * @method restore - * @memberof module:API.cvat.classes.Project - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.PluginError} - * @returns {number} ID of the imported project - */ - static async restore(file) { - const result = await PluginRegistry.apiWrapper.call(this, Project.restore, file); - return result; - } + /** + * Method restores a project from a backup + * @method restore + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + * @returns {number} ID of the imported project + */ + static async restore(storage: Storage, file: File | string) { + const result = await PluginRegistry.apiWrapper.call(this, Project.restore, storage, file); + return result; } +} - Object.defineProperties( - Project.prototype, - Object.freeze({ - annotations: Object.freeze({ - value: { - async exportDataset(format, saveImages, customName = '') { - const result = await PluginRegistry.apiWrapper.call( - this, - Project.prototype.annotations.exportDataset, - format, - saveImages, - customName, - ); - return result; - }, - async importDataset(format, file, updateStatusCallback = null) { - const result = await PluginRegistry.apiWrapper.call( - this, - Project.prototype.annotations.importDataset, - format, - file, - updateStatusCallback, - ); - return result; - }, +Object.defineProperties( + Project.prototype, + Object.freeze({ + annotations: Object.freeze({ + value: { + async exportDataset( + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + customName?: string, + ) { + const result = await PluginRegistry.apiWrapper.call( + this, + Project.prototype.annotations.exportDataset, + format, + saveImages, + useDefaultSettings, + targetStorage, + customName, + ); + return result; }, - writable: true, - }), + async importDataset( + format: string, + useDefaultSettings: boolean, + sourceStorage: Storage, + file: File | string, + updateStatusCallback = null, + ) { + const result = await PluginRegistry.apiWrapper.call( + this, + Project.prototype.annotations.importDataset, + format, + useDefaultSettings, + sourceStorage, + file, + updateStatusCallback, + ); + return result; + }, + }, + writable: true, }), - ); - - module.exports = { - Project, - }; -})(); + }), +); diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index bcc7e3add891..156f1f28ba38 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -1,639 +1,721 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const FormData = require('form-data'); - const { ServerError } = require('./exceptions'); - const store = require('store'); - const config = require('./config'); - const DownloadWorker = require('./download.worker'); - const Axios = require('axios'); - const tus = require('tus-js-client'); - - function enableOrganization() { - return { org: config.organizationID || '' }; +import { isEmail } from './common'; +import { StorageLocation, WebhookSourceType } from './enums'; +import { Storage } from './storage'; + +type Params = { + org: number | string, + use_default_location?: boolean, + location?: StorageLocation, + cloud_storage_id?: number, + format?: string, + filename?: string, + action?: string, +}; + +const FormData = require('form-data'); +const store = require('store'); +const Axios = require('axios'); +const tus = require('tus-js-client'); +const config = require('./config'); +const DownloadWorker = require('./download.worker'); +const { ServerError } = require('./exceptions'); + +function enableOrganization() { + return { org: config.organizationID || '' }; +} + +function configureStorage(storage: Storage, useDefaultLocation = false): Partial { + return { + use_default_location: useDefaultLocation, + ...(!useDefaultLocation ? { + location: storage.location, + ...(storage.cloudStorageId ? { + cloud_storage_id: storage.cloudStorageId, + } : {}), + } : {}), + }; +} + +function removeToken() { + Axios.defaults.headers.common.Authorization = ''; + store.remove('token'); +} + +function waitFor(frequencyHz, predicate) { + return new Promise((resolve, reject) => { + if (typeof predicate !== 'function') { + reject(new Error(`Predicate must be a function, got ${typeof predicate}`)); + } + + const internalWait = () => { + let result = false; + try { + result = predicate(); + } catch (error) { + reject(error); + } + + if (result) { + resolve(); + } else { + setTimeout(internalWait, 1000 / frequencyHz); + } + }; + + setTimeout(internalWait); + }); +} + +async function chunkUpload(file, uploadConfig) { + const params = enableOrganization(); + const { + endpoint, chunkSize, totalSize, onUpdate, metadata, + } = uploadConfig; + const { totalSentSize } = uploadConfig; + const uploadResult = { totalSentSize }; + return new Promise((resolve, reject) => { + const upload = new tus.Upload(file, { + endpoint, + metadata: { + filename: file.name, + filetype: file.type, + ...metadata, + }, + headers: { + Authorization: Axios.defaults.headers.common.Authorization, + }, + chunkSize, + retryDelays: null, + onError(error) { + reject(error); + }, + onBeforeRequest(req) { + const xhr = req.getUnderlyingObject(); + const { org } = params; + req.setHeader('X-Organization', org); + xhr.withCredentials = true; + }, + onProgress(bytesUploaded) { + if (onUpdate && Number.isInteger(totalSentSize) && Number.isInteger(totalSize)) { + const currentUploadedSize = totalSentSize + bytesUploaded; + const percentage = currentUploadedSize / totalSize; + onUpdate(percentage); + } + }, + onAfterResponse(request, response) { + const uploadFilename = response.getHeader('Upload-Filename'); + if (uploadFilename) uploadResult.filename = uploadFilename; + }, + onSuccess() { + if (totalSentSize) uploadResult.totalSentSize += file.size; + resolve(uploadResult); + }, + }); + upload.start(); + }); +} + +function generateError(errorData) { + if (errorData.response) { + const message = `${errorData.message}. ${JSON.stringify(errorData.response.data) || ''}.`; + return new ServerError(message, errorData.response.status); } - function removeToken() { - Axios.defaults.headers.common.Authorization = ''; - store.remove('token'); + // Server is unavailable (no any response) + const message = `${errorData.message}.`; // usually is "Error Network" + return new ServerError(message, 0); +} + +function prepareData(details) { + const data = new FormData(); + for (const [key, value] of Object.entries(details)) { + if (Array.isArray(value)) { + value.forEach((element, idx) => { + data.append(`${key}[${idx}]`, element); + }); + } else { + data.set(key, value); + } } + return data; +} + +class WorkerWrappedAxios { + constructor(requestInterseptor) { + const worker = new DownloadWorker(requestInterseptor); + const requests = {}; + let requestId = 0; + + worker.onmessage = (e) => { + if (e.data.id in requests) { + if (e.data.isSuccess) { + requests[e.data.id].resolve(e.data.responseData); + } else { + requests[e.data.id].reject({ + response: { + status: e.data.status, + data: e.data.responseData, + }, + }); + } - function waitFor(frequencyHz, predicate) { - return new Promise((resolve, reject) => { - if (typeof predicate !== 'function') { - reject(new Error(`Predicate must be a function, got ${typeof predicate}`)); + delete requests[e.data.id]; } + }; - const internalWait = () => { - let result = false; - try { - result = predicate(); - } catch (error) { - reject(error); - } + worker.onerror = (e) => { + if (e.data.id in requests) { + requests[e.data.id].reject(e); + delete requests[e.data.id]; + } + }; - if (result) { - resolve(); - } else { - setTimeout(internalWait, 1000 / frequencyHz); - } - }; + function getRequestId() { + return requestId++; + } - setTimeout(internalWait); - }); - } + async function get(url, requestConfig) { + return new Promise((resolve, reject) => { + const newRequestId = getRequestId(); + requests[newRequestId] = { + resolve, + reject, + }; + worker.postMessage({ + url, + config: requestConfig, + id: newRequestId, + }); + }); + } - async function chunkUpload(file, uploadConfig) { - const params = enableOrganization(); - const { - endpoint, chunkSize, totalSize, onUpdate, metadata, - } = uploadConfig; - const { totalSentSize } = uploadConfig; - const uploadResult = { totalSentSize }; - return new Promise((resolve, reject) => { - const upload = new tus.Upload(file, { - endpoint, - metadata: { - filename: file.name, - filetype: file.type, - ...metadata, - }, - headers: { - Authorization: Axios.defaults.headers.common.Authorization, - }, - chunkSize, - retryDelays: null, - onError(error) { - reject(error); - }, - onBeforeRequest(req) { - const xhr = req.getUnderlyingObject(); - const { org } = params; - req.setHeader('X-Organization', org); - xhr.withCredentials = true; - }, - onProgress(bytesUploaded) { - if (onUpdate && Number.isInteger(totalSentSize) && Number.isInteger(totalSize)) { - const currentUploadedSize = totalSentSize + bytesUploaded; - const percentage = currentUploadedSize / totalSize; - onUpdate(percentage); - } - }, - onAfterResponse(request, response) { - const uploadFilename = response.getHeader('Upload-Filename'); - if (uploadFilename) uploadResult.filename = uploadFilename; + Object.defineProperties( + this, + Object.freeze({ + get: { + value: get, + writable: false, }, - onSuccess() { - if (totalSentSize) uploadResult.totalSentSize += file.size; - resolve(uploadResult); - }, - }); - upload.start(); - }); + }), + ); } +} + +class ServerProxy { + constructor() { + Axios.defaults.withCredentials = true; + Axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'; + Axios.defaults.xsrfCookieName = 'csrftoken'; + const workerAxios = new WorkerWrappedAxios(); + Axios.interceptors.request.use((reqConfig) => { + if ('params' in reqConfig && 'org' in reqConfig.params) { + return reqConfig; + } + + reqConfig.params = { ...enableOrganization(), ...(reqConfig.params || {}) }; + return reqConfig; + }); - function generateError(errorData) { - if (errorData.response) { - const message = `${errorData.message}. ${JSON.stringify(errorData.response.data) || ''}.`; - return new ServerError(message, errorData.response.status); + let token = store.get('token'); + if (token) { + Axios.defaults.headers.common.Authorization = `Token ${token}`; } - // Server is unavailable (no any response) - const message = `${errorData.message}.`; // usually is "Error Network" - return new ServerError(message, 0); - } + async function about() { + const { backendAPI } = config; - function prepareData(details) { - const data = new FormData(); - for (const [key, value] of Object.entries(details)) { - if (Array.isArray(value)) { - value.forEach((element, idx) => { - data.append(`${key}[${idx}]`, element); + let response = null; + try { + response = await Axios.get(`${backendAPI}/server/about`, { + proxy: config.proxy, }); - } else { - data.set(key, value); + } catch (errorData) { + throw generateError(errorData); } + + return response.data; } - return data; - } - class WorkerWrappedAxios { - constructor(requestInterseptor) { - const worker = new DownloadWorker(requestInterseptor); - const requests = {}; - let requestId = 0; + async function share(directoryArg) { + const { backendAPI } = config; - worker.onmessage = (e) => { - if (e.data.id in requests) { - if (e.data.isSuccess) { - requests[e.data.id].resolve(e.data.responseData); - } else { - requests[e.data.id].reject({ - response: { - status: e.data.status, - data: e.data.responseData, - }, - }); - } + let response = null; + try { + response = await Axios.get(`${backendAPI}/server/share`, { + proxy: config.proxy, + params: { directory: directoryArg }, + }); + } catch (errorData) { + throw generateError(errorData); + } - delete requests[e.data.id]; - } - }; + return response.data; + } - worker.onerror = (e) => { - if (e.data.id in requests) { - requests[e.data.id].reject(e); - delete requests[e.data.id]; - } - }; + async function exception(exceptionObject) { + const { backendAPI } = config; - function getRequestId() { - return requestId++; + try { + await Axios.post(`${backendAPI}/server/exception`, JSON.stringify(exceptionObject), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function get(url, requestConfig) { - return new Promise((resolve, reject) => { - const newRequestId = getRequestId(); - requests[newRequestId] = { - resolve, - reject, - }; - worker.postMessage({ - url, - config: requestConfig, - id: newRequestId, - }); + async function formats() { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/server/annotation/formats`, { + proxy: config.proxy, }); + } catch (errorData) { + throw generateError(errorData); } - Object.defineProperties( - this, - Object.freeze({ - get: { - value: get, - writable: false, - }, - }), - ); + return response.data; } - } - class ServerProxy { - constructor() { - Axios.defaults.withCredentials = true; - Axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'; - Axios.defaults.xsrfCookieName = 'csrftoken'; - const workerAxios = new WorkerWrappedAxios(); - Axios.interceptors.request.use((reqConfig) => { - if ('params' in reqConfig && 'org' in reqConfig.params) { - return reqConfig; - } + async function userAgreements() { + const { backendAPI } = config; + let response = null; + try { + response = await Axios.get(`${backendAPI}/restrictions/user-agreements`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); + } - reqConfig.params = { ...enableOrganization(), ...(reqConfig.params || {}) }; - return reqConfig; - }); + return response.data; + } - let token = store.get('token'); - if (token) { - Axios.defaults.headers.common.Authorization = `Token ${token}`; + async function register(username, firstName, lastName, email, password1, password2, confirmations) { + let response = null; + try { + const data = JSON.stringify({ + username, + first_name: firstName, + last_name: lastName, + email, + password1, + password2, + confirmations, + }); + response = await Axios.post(`${config.backendAPI}/auth/register`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function about() { - const { backendAPI } = config; - - let response = null; - try { - response = await Axios.get(`${backendAPI}/server/about`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + return response.data; + } - return response.data; + async function login(credential, password) { + const authenticationData = [ + `${encodeURIComponent(isEmail(credential) ? 'email' : 'username')}=${encodeURIComponent(credential)}`, + `${encodeURIComponent('password')}=${encodeURIComponent(password)}`, + ] + .join('&') + .replace(/%20/g, '+'); + + removeToken(); + let authenticationResponse = null; + try { + authenticationResponse = await Axios.post(`${config.backendAPI}/auth/login`, authenticationData, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function share(directoryArg) { - const { backendAPI } = config; - const directory = encodeURI(directoryArg); + if (authenticationResponse.headers['set-cookie']) { + // Browser itself setup cookie and header is none + // In NodeJS we need do it manually + const cookies = authenticationResponse.headers['set-cookie'].join(';'); + Axios.defaults.headers.common.Cookie = cookies; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/server/share`, { - proxy: config.proxy, - params: { directory }, - }); - } catch (errorData) { - throw generateError(errorData); - } + token = authenticationResponse.data.key; + store.set('token', token); + Axios.defaults.headers.common.Authorization = `Token ${token}`; + } - return response.data; + async function logout() { + try { + await Axios.post(`${config.backendAPI}/auth/logout`, { + proxy: config.proxy, + }); + removeToken(); + } catch (errorData) { + throw generateError(errorData); } + } - async function exception(exceptionObject) { - const { backendAPI } = config; + async function changePassword(oldPassword, newPassword1, newPassword2) { + try { + const data = JSON.stringify({ + old_password: oldPassword, + new_password1: newPassword1, + new_password2: newPassword2, + }); + await Axios.post(`${config.backendAPI}/auth/password/change`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } - try { - await Axios.post(`${backendAPI}/server/exception`, JSON.stringify(exceptionObject), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function requestPasswordReset(email) { + try { + const data = JSON.stringify({ + email, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function formats() { - const { backendAPI } = config; + async function resetPassword(newPassword1, newPassword2, uid, _token) { + try { + const data = JSON.stringify({ + new_password1: newPassword1, + new_password2: newPassword2, + uid, + token: _token, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset/confirm`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/server/annotation/formats`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); + async function authorized() { + try { + await getSelf(); + } catch (serverError) { + if (serverError.code === 401) { + removeToken(); + return false; } - return response.data; + throw serverError; } - async function userAgreements() { - const { backendAPI } = config; - let response = null; - try { - response = await Axios.get(`${backendAPI}/restrictions/user-agreements`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + return true; + } - return response.data; + async function serverRequest(url, data) { + try { + return ( + await Axios({ + url, + ...data, + }) + ).data; + } catch (errorData) { + throw generateError(errorData); } + } - async function register(username, firstName, lastName, email, password1, password2, confirmations) { - let response = null; - try { - const data = JSON.stringify({ - username, - first_name: firstName, - last_name: lastName, - email, - password1, - password2, - confirmations, - }); - response = await Axios.post(`${config.backendAPI}/auth/register`, data, { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } - - return response.data; + async function searchProjectNames(search, limit) { + const { backendAPI, proxy } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/projects`, { + proxy, + params: { + names_only: true, + page: 1, + page_size: limit, + search, + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function login(username, password) { - const authenticationData = [ - `${encodeURIComponent('username')}=${encodeURIComponent(username)}`, - `${encodeURIComponent('password')}=${encodeURIComponent(password)}`, - ] - .join('&') - .replace(/%20/g, '+'); + response.data.results.count = response.data.count; + return response.data.results; + } - removeToken(); - let authenticationResponse = null; - try { - authenticationResponse = await Axios.post(`${config.backendAPI}/auth/login`, authenticationData, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getProjects(filter = {}) { + const { backendAPI, proxy } = config; - if (authenticationResponse.headers['set-cookie']) { - // Browser itself setup cookie and header is none - // In NodeJS we need do it manually - const cookies = authenticationResponse.headers['set-cookie'].join(';'); - Axios.defaults.headers.common.Cookie = cookies; + let response = null; + try { + if ('id' in filter) { + response = await Axios.get(`${backendAPI}/projects/${filter.id}`, { + proxy, + }); + const results = [response.data]; + results.count = 1; + return results; } - token = authenticationResponse.data.key; - store.set('token', token); - Axios.defaults.headers.common.Authorization = `Token ${token}`; + response = await Axios.get(`${backendAPI}/projects`, { + params: { + ...filter, + page_size: 12, + }, + proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function logout() { - try { - await Axios.post(`${config.backendAPI}/auth/logout`, { - proxy: config.proxy, - }); - removeToken(); - } catch (errorData) { - throw generateError(errorData); - } + response.data.results.count = response.data.count; + return response.data.results; + } + + async function saveProject(id, projectData) { + const { backendAPI } = config; + + try { + await Axios.patch(`${backendAPI}/projects/${id}`, JSON.stringify(projectData), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function changePassword(oldPassword, newPassword1, newPassword2) { - try { - const data = JSON.stringify({ - old_password: oldPassword, - new_password1: newPassword1, - new_password2: newPassword2, - }); - await Axios.post(`${config.backendAPI}/auth/password/change`, data, { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteProject(id) { + const { backendAPI } = config; + + try { + await Axios.delete(`${backendAPI}/projects/${id}`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function requestPasswordReset(email) { - try { - const data = JSON.stringify({ - email, - }); - await Axios.post(`${config.backendAPI}/auth/password/reset`, data, { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function createProject(projectSpec) { + const { backendAPI } = config; + + try { + const response = await Axios.post(`${backendAPI}/projects`, JSON.stringify(projectSpec), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function resetPassword(newPassword1, newPassword2, uid, _token) { - try { - const data = JSON.stringify({ - new_password1: newPassword1, - new_password2: newPassword2, - uid, - token: _token, - }); - await Axios.post(`${config.backendAPI}/auth/password/reset/confirm`, data, { + async function getTasks(filter = {}) { + const { backendAPI } = config; + + let response = null; + try { + if ('id' in filter) { + response = await Axios.get(`${backendAPI}/tasks/${filter.id}`, { proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, }); - } catch (errorData) { - throw generateError(errorData); + const results = [response.data]; + results.count = 1; + return results; } - } - async function authorized() { - try { - await module.exports.users.self(); - } catch (serverError) { - if (serverError.code === 401) { - removeToken(); - return false; - } + response = await Axios.get(`${backendAPI}/tasks`, { + params: { + ...filter, + page_size: 10, + }, + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); + } - throw serverError; - } + response.data.results.count = response.data.count; + return response.data.results; + } - return true; - } + async function saveTask(id, taskData) { + const { backendAPI } = config; - async function serverRequest(url, data) { - try { - return ( - await Axios({ - url, - ...data, - }) - ).data; - } catch (errorData) { - throw generateError(errorData); - } + let response = null; + try { + response = await Axios.patch(`${backendAPI}/tasks/${id}`, JSON.stringify(taskData), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function searchProjectNames(search, limit) { - const { backendAPI, proxy } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/projects`, { - proxy, - params: { - names_only: true, - page: 1, - page_size: limit, - search, - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteTask(id, organizationID = null) { + const { backendAPI } = config; - response.data.results.count = response.data.count; - return response.data.results; + try { + await Axios.delete(`${backendAPI}/tasks/${id}`, { + ...(organizationID ? { org: organizationID } : {}), + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function getProjects(filter = {}) { - const { backendAPI, proxy } = config; - - let response = null; - try { - if ('id' in filter) { - response = await Axios.get(`${backendAPI}/projects/${filter.id}`, { - proxy, - }); - const results = [response.data]; - results.count = 1; - return results; - } + function exportDataset(instanceType) { + return async function ( + id: number, + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + name?: string, + ) { + const { backendAPI } = config; + const baseURL = `${backendAPI}/${instanceType}/${id}/${saveImages ? 'dataset' : 'annotations'}`; + const params: Params = { + ...enableOrganization(), + ...configureStorage(targetStorage, useDefaultSettings), + ...(name ? { filename: name.replace(/\//g, '_') } : {}), + format, + }; - response = await Axios.get(`${backendAPI}/projects`, { - params: { - ...filter, - page_size: 12, - }, - proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } - - response.data.results.count = response.data.count; - return response.data.results; - } - - async function saveProject(id, projectData) { - const { backendAPI } = config; - - try { - await Axios.patch(`${backendAPI}/projects/${id}`, JSON.stringify(projectData), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } - } - - async function deleteProject(id) { - const { backendAPI } = config; - - try { - await Axios.delete(`${backendAPI}/projects/${id}`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } - } - - async function createProject(projectSpec) { - const { backendAPI } = config; - - try { - const response = await Axios.post(`${backendAPI}/projects`, JSON.stringify(projectSpec), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } - } - - async function getTasks(filter = {}) { - const { backendAPI } = config; - - let response = null; - try { - if ('id' in filter) { - response = await Axios.get(`${backendAPI}/tasks/${filter.id}`, { + return new Promise((resolve, reject) => { + async function request() { + Axios.get(baseURL, { proxy: config.proxy, - }); - const results = [response.data]; - results.count = 1; - return results; + params, + }) + .then((response) => { + const isCloudStorage = targetStorage.location === StorageLocation.CLOUD_STORAGE; + const { status } = response; + if (status === 201) params.action = 'download'; + if (status === 202 || (isCloudStorage && status === 201)) { + setTimeout(request, 3000); + } else if (status === 201) { + resolve(`${baseURL}?${new URLSearchParams(params).toString()}`); + } else if (isCloudStorage && status === 200) { + resolve(); + } + }) + .catch((errorData) => { + reject(generateError(errorData)); + }); } - response = await Axios.get(`${backendAPI}/tasks`, { - params: { - ...filter, - page_size: 10, - }, - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } - - response.data.results.count = response.data.count; - return response.data.results; - } + setTimeout(request); + }); + }; + } - async function saveTask(id, taskData) { - const { backendAPI } = config; + async function importDataset( + id: number, + format: string, + useDefaultLocation: boolean, + sourceStorage: Storage, + file: File | string, + onUpdate, + ) { + const { backendAPI, origin } = config; + const params: Params = { + ...enableOrganization(), + ...configureStorage(sourceStorage, useDefaultLocation), + format, + filename: typeof file === 'string' ? file : file.name, + }; - let response = null; - try { - response = await Axios.patch(`${backendAPI}/tasks/${id}`, JSON.stringify(taskData), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + const url = `${backendAPI}/projects/${id}/dataset`; - return response.data; + async function wait() { + return new Promise((resolve, reject) => { + async function requestStatus() { + try { + const response = await Axios.get(url, { + params: { ...params, action: 'import_status' }, + proxy: config.proxy, + }); + if (response.status === 202) { + if (onUpdate && response.data.message) { + onUpdate(response.data.message, response.data.progress || 0); + } + setTimeout(requestStatus, 3000); + } else if (response.status === 201) { + resolve(); + } else { + reject(generateError(response)); + } + } catch (error) { + reject(generateError(error)); + } + } + setTimeout(requestStatus, 2000); + }); } + const isCloudStorage = sourceStorage.location === StorageLocation.CLOUD_STORAGE; - async function deleteTask(id, organizationID = null) { - const { backendAPI } = config; - + if (isCloudStorage) { try { - await Axios.delete(`${backendAPI}/tasks/${id}`, { - ...(organizationID ? { org: organizationID } : {}), - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); + await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + }); } catch (errorData) { throw generateError(errorData); } - } - - function exportDataset(instanceType) { - return async function (id, format, name, saveImages) { - const { backendAPI } = config; - const baseURL = `${backendAPI}/${instanceType}/${id}/${saveImages ? 'dataset' : 'annotations'}`; - const params = { - ...enableOrganization(), - format, - }; - - if (name) { - params.filename = name.replace(/\//g, '_'); - } - - return new Promise((resolve, reject) => { - async function request() { - Axios.get(baseURL, { - proxy: config.proxy, - params, - }) - .then((response) => { - if (response.status === 202) { - setTimeout(request, 3000); - } else { - params.action = 'download'; - resolve(`${baseURL}?${new URLSearchParams(params).toString()}`); - } - }) - .catch((errorData) => { - reject(generateError(errorData)); - }); - } - - setTimeout(request); - }); - }; - } - - async function importDataset(id, format, file, onUpdate) { - const { backendAPI, origin } = config; - const params = { - ...enableOrganization(), - format, - filename: file.name, - }; + } else { const uploadConfig = { chunkSize: config.uploadChunkSize * 1024 * 1024, endpoint: `${origin}${backendAPI}/projects/${id}/dataset/`, totalSentSize: 0, - totalSize: file.size, + totalSize: (file as File).size, onUpdate: (percentage) => { onUpdate('The dataset is being uploaded to the server', percentage); }, }; - const url = `${backendAPI}/projects/${id}/dataset`; try { await Axios.post(url, @@ -649,77 +731,103 @@ proxy: config.proxy, headers: { 'Upload-Finish': true }, }); - return new Promise((resolve, reject) => { - async function requestStatus() { - try { - const response = await Axios.get(url, { - params: { ...params, action: 'import_status' }, - proxy: config.proxy, - }); - if (response.status === 202) { - if (onUpdate && response.data.message) { - onUpdate(response.data.message, response.data.progress || 0); - } - setTimeout(requestStatus, 3000); - } else if (response.status === 201) { - resolve(); - } else { - reject(generateError(response)); - } - } catch (error) { - reject(generateError(error)); - } - } - setTimeout(requestStatus, 2000); - }); } catch (errorData) { throw generateError(errorData); } } + try { + return wait(); + } catch (errorData) { + throw generateError(errorData); + } + } - async function exportTask(id) { - const { backendAPI } = config; - const params = { - ...enableOrganization(), - }; - const url = `${backendAPI}/tasks/${id}/backup`; + async function backupTask(id: number, targetStorage: Storage, useDefaultSettings: boolean, fileName?: string) { + const { backendAPI } = config; + const params: Params = { + ...enableOrganization(), + ...configureStorage(targetStorage, useDefaultSettings), + ...(fileName ? { filename: fileName } : {}), + }; + const url = `${backendAPI}/tasks/${id}/backup`; + + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.get(url, { + proxy: config.proxy, + params, + }); + const isCloudStorage = targetStorage.location === StorageLocation.CLOUD_STORAGE; + const { status } = response; + if (status === 201) params.action = 'download'; + if (status === 202 || (isCloudStorage && status === 201)) { + setTimeout(request, 3000); + } else if (status === 201) { + resolve(`${url}?${new URLSearchParams(params).toString()}`); + } else if (isCloudStorage && status === 200) { + resolve(); + } + } catch (errorData) { + reject(generateError(errorData)); + } + } + + setTimeout(request); + }); + } + + async function restoreTask(storage: Storage, file: File | string) { + const { backendAPI } = config; + // keep current default params to 'freeze" them during this request + const params: Params = { + ...enableOrganization(), + ...configureStorage(storage), + }; + const url = `${backendAPI}/tasks/backup`; + const taskData = new FormData(); + let response; + + async function wait() { return new Promise((resolve, reject) => { - async function request() { + async function checkStatus() { try { - const response = await Axios.get(url, { + taskData.set('rq_id', response.data.rq_id); + response = await Axios.post(url, taskData, { proxy: config.proxy, params, }); if (response.status === 202) { - setTimeout(request, 3000); + setTimeout(checkStatus, 3000); } else { - params.action = 'download'; - resolve(`${url}?${new URLSearchParams(params).toString()}`); + // to be able to get the task after it was created, pass frozen params + const importedTask = await getTasks({ id: response.data.id, ...params }); + resolve(importedTask[0]); } } catch (errorData) { reject(generateError(errorData)); } } - - setTimeout(request); + setTimeout(checkStatus); }); } + const isCloudStorage = storage.location === StorageLocation.CLOUD_STORAGE; - async function importTask(file) { - const { backendAPI } = config; - // keep current default params to 'freeze" them during this request - const params = enableOrganization(); - - const taskData = new FormData(); + if (isCloudStorage) { + params.filename = file as string; + response = await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + }); + } else { const uploadConfig = { chunkSize: config.uploadChunkSize * 1024 * 1024, endpoint: `${origin}${backendAPI}/tasks/backup/`, totalSentSize: 0, - totalSize: file.size, + totalSize: (file as File).size, }; - - const url = `${backendAPI}/tasks/backup`; await Axios.post(url, new FormData(), { params, @@ -727,55 +835,85 @@ headers: { 'Upload-Start': true }, }); const { filename } = await chunkUpload(file, uploadConfig); - let response = await Axios.post(url, + response = await Axios.post(url, new FormData(), { params: { ...params, filename }, proxy: config.proxy, headers: { 'Upload-Finish': true }, }); + } + return wait(); + } - return new Promise((resolve, reject) => { - async function checkStatus() { - try { - taskData.set('rq_id', response.data.rq_id); - response = await Axios.post(url, taskData, { - proxy: config.proxy, - params, - }); - if (response.status === 202) { - setTimeout(checkStatus, 3000); - } else { - // to be able to get the task after it was created, pass frozen params - const importedTask = await getTasks({ id: response.data.id, ...params }); - resolve(importedTask[0]); - } - } catch (errorData) { - reject(generateError(errorData)); + async function backupProject( + id: number, + targetStorage: Storage, + useDefaultSettings: boolean, + fileName?: string, + ) { + const { backendAPI } = config; + // keep current default params to 'freeze" them during this request + const params: Params = { + ...enableOrganization(), + ...configureStorage(targetStorage, useDefaultSettings), + ...(fileName ? { filename: fileName } : {}), + }; + + const url = `${backendAPI}/projects/${id}/backup`; + + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.get(url, { + proxy: config.proxy, + params, + }); + const isCloudStorage = targetStorage.location === StorageLocation.CLOUD_STORAGE; + const { status } = response; + if (status === 201) params.action = 'download'; + if (status === 202 || (isCloudStorage && status === 201)) { + setTimeout(request, 3000); + } else if (status === 201) { + resolve(`${url}?${new URLSearchParams(params).toString()}`); + } else if (isCloudStorage && status === 200) { + resolve(); } + } catch (errorData) { + reject(generateError(errorData)); } + } - setTimeout(checkStatus); - }); - } + setTimeout(request); + }); + } - async function backupProject(id) { - const { backendAPI } = config; - // keep current default params to 'freeze" them during this request - const params = enableOrganization(); - const url = `${backendAPI}/projects/${id}/backup`; + async function restoreProject(storage: Storage, file: File | string) { + const { backendAPI } = config; + // keep current default params to 'freeze" them during this request + const params: Params = { + ...enableOrganization(), + ...configureStorage(storage), + }; + + const url = `${backendAPI}/projects/backup`; + const projectData = new FormData(); + let response; + async function wait() { return new Promise((resolve, reject) => { async function request() { try { - const response = await Axios.get(url, { + projectData.set('rq_id', response.data.rq_id); + response = await Axios.post(`${backendAPI}/projects/backup`, projectData, { proxy: config.proxy, params, }); if (response.status === 202) { setTimeout(request, 3000); } else { - params.action = 'download'; - resolve(`${url}?${new URLSearchParams(params).toString()}`); + // to be able to get the task after it was created, pass frozen params + const restoredProject = await getProjects({ id: response.data.id, ...params }); + resolve(restoredProject[0]); } } catch (errorData) { reject(generateError(errorData)); @@ -786,20 +924,22 @@ }); } - async function restoreProject(file) { - const { backendAPI } = config; - // keep current default params to 'freeze" them during this request - const params = enableOrganization(); + const isCloudStorage = storage.location === StorageLocation.CLOUD_STORAGE; - const projectData = new FormData(); + if (isCloudStorage) { + params.filename = file; + response = await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + }); + } else { const uploadConfig = { chunkSize: config.uploadChunkSize * 1024 * 1024, endpoint: `${origin}${backendAPI}/projects/backup/`, totalSentSize: 0, - totalSize: file.size, + totalSize: (file as File).size, }; - - const url = `${backendAPI}/projects/backup`; await Axios.post(url, new FormData(), { params, @@ -807,1239 +947,1413 @@ headers: { 'Upload-Start': true }, }); const { filename } = await chunkUpload(file, uploadConfig); - let response = await Axios.post(url, + response = await Axios.post(url, new FormData(), { params: { ...params, filename }, proxy: config.proxy, headers: { 'Upload-Finish': true }, }); + } + return wait(); + } + async function createTask(taskSpec, taskDataSpec, onUpdate) { + const { backendAPI, origin } = config; + // keep current default params to 'freeze" them during this request + const params = enableOrganization(); + + async function wait(id) { return new Promise((resolve, reject) => { - async function request() { + async function checkStatus() { try { - projectData.set('rq_id', response.data.rq_id); - response = await Axios.post(`${backendAPI}/projects/backup`, projectData, { - proxy: config.proxy, - params, - }); - if (response.status === 202) { - setTimeout(request, 3000); + const response = await Axios.get(`${backendAPI}/tasks/${id}/status`, { params }); + if (['Queued', 'Started'].includes(response.data.state)) { + if (response.data.message !== '') { + onUpdate(response.data.message, response.data.progress || 0); + } + setTimeout(checkStatus, 1000); + } else if (response.data.state === 'Finished') { + resolve(); + } else if (response.data.state === 'Failed') { + // If request has been successful, but task hasn't been created + // Then passed data is wrong and we can pass code 400 + const message = ` + Could not create the task on the server. ${response.data.message}. + `; + reject(new ServerError(message, 400)); } else { - // to be able to get the task after it was created, pass frozen params - const restoredProject = await getProjects({ id: response.data.id, ...params }); - resolve(restoredProject[0]); + // If server has another status, it is unexpected + // Therefore it is server error and we can pass code 500 + reject( + new ServerError( + `Unknown task state has been received: ${response.data.state}`, + 500, + ), + ); } } catch (errorData) { reject(generateError(errorData)); } } - setTimeout(request); + setTimeout(checkStatus, 1000); }); } - async function createTask(taskSpec, taskDataSpec, onUpdate) { - const { backendAPI, origin } = config; - // keep current default params to 'freeze" them during this request - const params = enableOrganization(); + const chunkSize = config.uploadChunkSize * 1024 * 1024; + const clientFiles = taskDataSpec.client_files; + const chunkFiles = []; + const bulkFiles = []; + let totalSize = 0; + let totalSentSize = 0; + for (const file of clientFiles) { + if (file.size > chunkSize) { + chunkFiles.push(file); + } else { + bulkFiles.push(file); + } + totalSize += file.size; + } + delete taskDataSpec.client_files; - async function wait(id) { - return new Promise((resolve, reject) => { - async function checkStatus() { - try { - const response = await Axios.get(`${backendAPI}/tasks/${id}/status`, { params }); - if (['Queued', 'Started'].includes(response.data.state)) { - if (response.data.message !== '') { - onUpdate(response.data.message, response.data.progress || 0); - } - setTimeout(checkStatus, 1000); - } else if (response.data.state === 'Finished') { - resolve(); - } else if (response.data.state === 'Failed') { - // If request has been successful, but task hasn't been created - // Then passed data is wrong and we can pass code 400 - const message = ` - Could not create the task on the server. ${response.data.message}. - `; - reject(new ServerError(message, 400)); - } else { - // If server has another status, it is unexpected - // Therefore it is server error and we can pass code 500 - reject( - new ServerError( - `Unknown task state has been received: ${response.data.state}`, - 500, - ), - ); - } - } catch (errorData) { - reject(generateError(errorData)); - } - } - - setTimeout(checkStatus, 1000); + const taskData = new FormData(); + for (const [key, value] of Object.entries(taskDataSpec)) { + if (Array.isArray(value)) { + value.forEach((element, idx) => { + taskData.append(`${key}[${idx}]`, element); }); + } else { + taskData.set(key, value); } + } - const chunkSize = config.uploadChunkSize * 1024 * 1024; - const clientFiles = taskDataSpec.client_files; - const chunkFiles = []; - const bulkFiles = []; - let totalSize = 0; - let totalSentSize = 0; - for (const file of clientFiles) { - if (file.size > chunkSize) { - chunkFiles.push(file); - } else { - bulkFiles.push(file); - } - totalSize += file.size; - } - delete taskDataSpec.client_files; + let response = null; - const taskData = new FormData(); - for (const [key, value] of Object.entries(taskDataSpec)) { - if (Array.isArray(value)) { - value.forEach((element, idx) => { - taskData.append(`${key}[${idx}]`, element); - }); - } else { - taskData.set(key, value); - } - } + onUpdate('The task is being created on the server..', null); + try { + response = await Axios.post(`${backendAPI}/tasks`, JSON.stringify(taskSpec), { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } - let response = null; + onUpdate('The data are being uploaded to the server..', null); - onUpdate('The task is being created on the server..', null); - try { - response = await Axios.post(`${backendAPI}/tasks`, JSON.stringify(taskSpec), { + async function bulkUpload(taskId, files) { + const fileBulks = files.reduce((fileGroups, file) => { + const lastBulk = fileGroups[fileGroups.length - 1]; + if (chunkSize - lastBulk.size >= file.size) { + lastBulk.files.push(file); + lastBulk.size += file.size; + } else { + fileGroups.push({ files: [file], size: file.size }); + } + return fileGroups; + }, [{ files: [], size: 0 }]); + const totalBulks = fileBulks.length; + let currentChunkNumber = 0; + while (currentChunkNumber < totalBulks) { + for (const [idx, element] of fileBulks[currentChunkNumber].files.entries()) { + taskData.append(`client_files[${idx}]`, element); + } + const percentage = totalSentSize / totalSize; + onUpdate('The data are being uploaded to the server', percentage); + await Axios.post(`${backendAPI}/tasks/${taskId}/data`, taskData, { + ...params, proxy: config.proxy, - params, - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Upload-Multiple': true }, }); - } catch (errorData) { - throw generateError(errorData); + for (let i = 0; i < fileBulks[currentChunkNumber].files.length; i++) { + taskData.delete(`client_files[${i}]`); + } + totalSentSize += fileBulks[currentChunkNumber].size; + currentChunkNumber++; } + } - onUpdate('The data are being uploaded to the server..', null); - - async function bulkUpload(taskId, files) { - const fileBulks = files.reduce((fileGroups, file) => { - const lastBulk = fileGroups[fileGroups.length - 1]; - if (chunkSize - lastBulk.size >= file.size) { - lastBulk.files.push(file); - lastBulk.size += file.size; - } else { - fileGroups.push({ files: [file], size: file.size }); - } - return fileGroups; - }, [{ files: [], size: 0 }]); - const totalBulks = fileBulks.length; - let currentChunkNumber = 0; - while (currentChunkNumber < totalBulks) { - for (const [idx, element] of fileBulks[currentChunkNumber].files.entries()) { - taskData.append(`client_files[${idx}]`, element); - } - const percentage = totalSentSize / totalSize; + try { + await Axios.post(`${backendAPI}/tasks/${response.data.id}/data`, + taskData, { + ...params, + proxy: config.proxy, + headers: { 'Upload-Start': true }, + }); + const uploadConfig = { + endpoint: `${origin}${backendAPI}/tasks/${response.data.id}/data/`, + onUpdate: (percentage) => { onUpdate('The data are being uploaded to the server', percentage); - await Axios.post(`${backendAPI}/tasks/${taskId}/data`, taskData, { - ...params, - proxy: config.proxy, - headers: { 'Upload-Multiple': true }, - }); - for (let i = 0; i < fileBulks[currentChunkNumber].files.length; i++) { - taskData.delete(`client_files[${i}]`); - } - totalSentSize += fileBulks[currentChunkNumber].size; - currentChunkNumber++; - } + }, + chunkSize, + totalSize, + totalSentSize, + }; + for (const file of chunkFiles) { + uploadConfig.totalSentSize += await chunkUpload(file, uploadConfig); } - - try { - await Axios.post(`${backendAPI}/tasks/${response.data.id}/data`, - taskData, { - ...params, - proxy: config.proxy, - headers: { 'Upload-Start': true }, - }); - const uploadConfig = { - endpoint: `${origin}${backendAPI}/tasks/${response.data.id}/data/`, - onUpdate: (percentage) => { - onUpdate('The data are being uploaded to the server', percentage); - }, - chunkSize, - totalSize, - totalSentSize, - }; - for (const file of chunkFiles) { - uploadConfig.totalSentSize += await chunkUpload(file, uploadConfig); - } - if (bulkFiles.length > 0) { - await bulkUpload(response.data.id, bulkFiles); - } - await Axios.post(`${backendAPI}/tasks/${response.data.id}/data`, - taskData, { - ...params, - proxy: config.proxy, - headers: { 'Upload-Finish': true }, - }); - } catch (errorData) { - try { - await deleteTask(response.data.id, params.org || null); - } catch (_) { - // ignore - } - throw generateError(errorData); + if (bulkFiles.length > 0) { + await bulkUpload(response.data.id, bulkFiles); } - + await Axios.post(`${backendAPI}/tasks/${response.data.id}/data`, + taskData, { + ...params, + proxy: config.proxy, + headers: { 'Upload-Finish': true }, + }); + } catch (errorData) { try { - await wait(response.data.id); - } catch (createException) { await deleteTask(response.data.id, params.org || null); - throw createException; + } catch (_) { + // ignore } + throw generateError(errorData); + } - // to be able to get the task after it was created, pass frozen params - const createdTask = await getTasks({ id: response.data.id, ...params }); - return createdTask[0]; + try { + await wait(response.data.id); + } catch (createException) { + await deleteTask(response.data.id, params.org || null); + throw createException; } - async function getJobs(filter = {}) { - const { backendAPI } = config; - const id = filter.id || null; + // to be able to get the task after it was created, pass frozen params + const createdTask = await getTasks({ id: response.data.id, ...params }); + return createdTask[0]; + } - let response = null; - try { - if (id !== null) { - response = await Axios.get(`${backendAPI}/jobs/${id}`, { - proxy: config.proxy, - }); - } else { - response = await Axios.get(`${backendAPI}/jobs`, { - proxy: config.proxy, - params: { - ...filter, - page_size: 12, - }, - }); - } - } catch (errorData) { - throw generateError(errorData); + async function getJobs(filter = {}) { + const { backendAPI } = config; + const id = filter.id || null; + + let response = null; + try { + if (id !== null) { + response = await Axios.get(`${backendAPI}/jobs/${id}`, { + proxy: config.proxy, + }); + } else { + response = await Axios.get(`${backendAPI}/jobs`, { + proxy: config.proxy, + params: { + ...filter, + page_size: 12, + }, + }); } + } catch (errorData) { + throw generateError(errorData); + } - return response.data; + return response.data; + } + + async function getJobIssues(jobID) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/jobs/${jobID}/issues`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getJobIssues(jobID) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/jobs/${jobID}/issues`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function createComment(data) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.post(`${backendAPI}/comments`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function createComment(data) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.post(`${backendAPI}/comments`, JSON.stringify(data), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function createIssue(data) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.post(`${backendAPI}/issues`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function createIssue(data) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.post(`${backendAPI}/issues`, JSON.stringify(data), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function updateIssue(issueID, data) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.patch(`${backendAPI}/issues/${issueID}`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function updateIssue(issueID, data) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.patch(`${backendAPI}/issues/${issueID}`, JSON.stringify(data), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteIssue(issueID) { + const { backendAPI } = config; - return response.data; + try { + await Axios.delete(`${backendAPI}/issues/${issueID}`); + } catch (errorData) { + throw generateError(errorData); } + } - async function deleteIssue(issueID) { - const { backendAPI } = config; + async function saveJob(id, jobData) { + const { backendAPI } = config; - try { - await Axios.delete(`${backendAPI}/issues/${issueID}`); - } catch (errorData) { - throw generateError(errorData); - } + let response = null; + try { + response = await Axios.patch(`${backendAPI}/jobs/${id}`, JSON.stringify(jobData), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function saveJob(id, jobData) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.patch(`${backendAPI}/jobs/${id}`, JSON.stringify(jobData), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getUsers(filter = { page_size: 'all' }) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.get(`${backendAPI}/users`, { + proxy: config.proxy, + params: { + ...filter, + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getUsers(filter = { page_size: 'all' }) { - const { backendAPI } = config; + return response.data.results; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/users`, { - proxy: config.proxy, - params: { - ...filter, - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getSelf() { + const { backendAPI } = config; - return response.data.results; + let response = null; + try { + response = await Axios.get(`${backendAPI}/users/self`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getSelf() { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/users/self`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getPreview(tid, jid) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + const url = `${backendAPI}/${jid !== null ? 'jobs' : 'tasks'}/${jid || tid}/data`; + response = await Axios.get(url, { + params: { + type: 'preview', + }, + proxy: config.proxy, + responseType: 'blob', + }); + } catch (errorData) { + const code = errorData.response ? errorData.response.status : errorData.code; + throw new ServerError(`Could not get preview frame for the task ${tid} from the server`, code); } - async function getPreview(tid, jid) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - const url = `${backendAPI}/${jid !== null ? 'jobs' : 'tasks'}/${jid || tid}/data`; - response = await Axios.get(url, { - params: { - type: 'preview', - }, - proxy: config.proxy, - responseType: 'blob', - }); - } catch (errorData) { - const code = errorData.response ? errorData.response.status : errorData.code; - throw new ServerError(`Could not get preview frame for the task ${tid} from the server`, code); - } + async function getImageContext(jid, frame) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.get(`${backendAPI}/jobs/${jid}/data`, { + params: { + quality: 'original', + type: 'context_image', + number: frame, + }, + proxy: config.proxy, + responseType: 'blob', + }); + } catch (errorData) { + throw generateError(errorData); } - async function getImageContext(jid, frame) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/jobs/${jid}/data`, { - params: { - quality: 'original', - type: 'context_image', - number: frame, - }, - proxy: config.proxy, - responseType: 'blob', - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getData(tid, jid, chunk) { + const { backendAPI } = config; - return response.data; - } + const url = jid === null ? `tasks/${tid}/data` : `jobs/${jid}/data`; - async function getData(tid, jid, chunk) { - const { backendAPI } = config; + let response = null; + try { + response = await workerAxios.get(`${backendAPI}/${url}`, { + params: { + ...enableOrganization(), + quality: 'compressed', + type: 'chunk', + number: chunk, + }, + proxy: config.proxy, + responseType: 'arraybuffer', + }); + } catch (errorData) { + throw generateError({ + message: '', + response: { + ...errorData.response, + data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)), + }, + }); + } - const url = jid === null ? `tasks/${tid}/data` : `jobs/${jid}/data`; + return response; + } - let response = null; - try { - response = await workerAxios.get(`${backendAPI}/${url}`, { - params: { - ...enableOrganization(), - quality: 'compressed', - type: 'chunk', - number: chunk, - }, - proxy: config.proxy, - responseType: 'arraybuffer', - }); - } catch (errorData) { - throw generateError({ - message: '', - response: { - ...errorData.response, - data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)), - }, - }); - } + async function getMeta(session, jid) { + const { backendAPI } = config; - return response; + let response = null; + try { + response = await Axios.get(`${backendAPI}/${session}s/${jid}/data/meta`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getMeta(session, jid) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/${session}s/${jid}/data/meta`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function saveMeta(session, jid, meta) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.patch(`${backendAPI}/${session}s/${jid}/data/meta`, meta, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function saveMeta(session, jid, meta) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.patch(`${backendAPI}/${session}s/${jid}/data/meta`, meta, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + // Session is 'task' or 'job' + async function getAnnotations(session, id) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.get(`${backendAPI}/${session}s/${id}/annotations`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - // Session is 'task' or 'job' - async function getAnnotations(session, id) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/${session}s/${id}/annotations`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + // Session is 'task' or 'job' + async function updateAnnotations(session, id, data, action) { + const { backendAPI } = config; + const url = `${backendAPI}/${session}s/${id}/annotations`; + const params = {}; + let requestFunc = null; - return response.data; + if (action.toUpperCase() === 'PUT') { + requestFunc = Axios.put.bind(Axios); + } else { + requestFunc = Axios.patch.bind(Axios); + params.action = action; } - // Session is 'task' or 'job' - async function updateAnnotations(session, id, data, action) { - const { backendAPI } = config; - const url = `${backendAPI}/${session}s/${id}/annotations`; - const params = {}; - let requestFunc = null; + let response = null; + try { + response = await requestFunc(url, JSON.stringify(data), { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } - if (action.toUpperCase() === 'PUT') { - requestFunc = Axios.put.bind(Axios); - } else { - requestFunc = Axios.patch.bind(Axios); - params.action = action; - } + return response.data; + } - let response = null; + // Session is 'task' or 'job' + async function uploadAnnotations( + session, + id: number, + format: string, + useDefaultLocation: boolean, + sourceStorage: Storage, + file: File | string, + ) { + const { backendAPI, origin } = config; + const params: Params = { + ...enableOrganization(), + ...configureStorage(sourceStorage, useDefaultLocation), + format, + filename: typeof file === 'string' ? file : file.name, + }; + + const url = `${backendAPI}/${session}s/${id}/annotations`; + + async function wait() { + return new Promise((resolve, reject) => { + async function requestStatus() { + try { + const response = await Axios.put( + url, + new FormData(), + { + params, + proxy: config.proxy, + }, + ); + if (response.status === 202) { + setTimeout(requestStatus, 3000); + } else { + resolve(); + } + } catch (errorData) { + reject(generateError(errorData)); + } + } + setTimeout(requestStatus); + }); + } + const isCloudStorage = sourceStorage.location === StorageLocation.CLOUD_STORAGE; + + if (isCloudStorage) { try { - response = await requestFunc(url, JSON.stringify(data), { - proxy: config.proxy, - params, - headers: { - 'Content-Type': 'application/json', - }, - }); + await Axios.post(url, + new FormData(), { + params, + proxy: config.proxy, + }); } catch (errorData) { throw generateError(errorData); } - - return response.data; - } - - // Session is 'task' or 'job' - async function uploadAnnotations(session, id, file, format) { - const { backendAPI, origin } = config; - const params = { - ...enableOrganization(), - format, - filename: file.name, - }; + } else { const chunkSize = config.uploadChunkSize * 1024 * 1024; const uploadConfig = { chunkSize, endpoint: `${origin}${backendAPI}/${session}s/${id}/annotations/`, }; + try { - await Axios.post(`${backendAPI}/${session}s/${id}/annotations`, + await Axios.post(url, new FormData(), { params, proxy: config.proxy, headers: { 'Upload-Start': true }, }); await chunkUpload(file, uploadConfig); - await Axios.post(`${backendAPI}/${session}s/${id}/annotations`, + await Axios.post(url, new FormData(), { params, proxy: config.proxy, headers: { 'Upload-Finish': true }, }); - return new Promise((resolve, reject) => { - async function requestStatus() { - try { - const response = await Axios.put( - `${backendAPI}/${session}s/${id}/annotations`, - new FormData(), - { - params, - proxy: config.proxy, - }, - ); - if (response.status === 202) { - setTimeout(requestStatus, 3000); - } else { - resolve(); - } - } catch (errorData) { - reject(generateError(errorData)); - } - } - setTimeout(requestStatus); - }); } catch (errorData) { throw generateError(errorData); } } - // Session is 'task' or 'job' - async function dumpAnnotations(id, name, format) { - const { backendAPI } = config; - const baseURL = `${backendAPI}/tasks/${id}/annotations`; - const params = enableOrganization(); - params.format = encodeURIComponent(format); - if (name) { - const filename = name.replace(/\//g, '_'); - params.filename = encodeURIComponent(filename); - } + try { + return wait(); + } catch (errorData) { + throw generateError(errorData); + } + } - return new Promise((resolve, reject) => { - async function request() { - Axios.get(baseURL, { - proxy: config.proxy, - params, + // Session is 'task' or 'job' + async function dumpAnnotations(id, name, format) { + const { backendAPI } = config; + const baseURL = `${backendAPI}/tasks/${id}/annotations`; + const params = enableOrganization(); + params.format = encodeURIComponent(format); + if (name) { + const filename = name.replace(/\//g, '_'); + params.filename = encodeURIComponent(filename); + } + + return new Promise((resolve, reject) => { + async function request() { + Axios.get(baseURL, { + proxy: config.proxy, + params, + }) + .then((response) => { + if (response.status === 202) { + setTimeout(request, 3000); + } else { + params.action = 'download'; + resolve(`${baseURL}?${new URLSearchParams(params).toString()}`); + } }) - .then((response) => { - if (response.status === 202) { - setTimeout(request, 3000); - } else { - params.action = 'download'; - resolve(`${baseURL}?${new URLSearchParams(params).toString()}`); - } - }) - .catch((errorData) => { - reject(generateError(errorData)); - }); - } + .catch((errorData) => { + reject(generateError(errorData)); + }); + } - setTimeout(request); + setTimeout(request); + }); + } + + async function saveLogs(logs) { + const { backendAPI } = config; + + try { + await Axios.post(`${backendAPI}/server/logs`, JSON.stringify(logs), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, }); + } catch (errorData) { + throw generateError(errorData); } + } - async function saveLogs(logs) { - const { backendAPI } = config; + async function getLambdaFunctions() { + const { backendAPI } = config; - try { - await Axios.post(`${backendAPI}/server/logs`, JSON.stringify(logs), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + try { + const response = await Axios.get(`${backendAPI}/lambda/functions`, { + proxy: config.proxy, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function getLambdaFunctions() { - const { backendAPI } = config; + async function runLambdaRequest(body) { + const { backendAPI } = config; - try { - const response = await Axios.get(`${backendAPI}/lambda/functions`, { - proxy: config.proxy, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + try { + const response = await Axios.post(`${backendAPI}/lambda/requests`, JSON.stringify(body), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function runLambdaRequest(body) { - const { backendAPI } = config; + async function callLambdaFunction(funId, body) { + const { backendAPI } = config; - try { - const response = await Axios.post(`${backendAPI}/lambda/requests`, JSON.stringify(body), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); + try { + const response = await Axios.post(`${backendAPI}/lambda/functions/${funId}`, JSON.stringify(body), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function callLambdaFunction(funId, body) { - const { backendAPI } = config; + async function getLambdaRequests() { + const { backendAPI } = config; - try { - const response = await Axios.post(`${backendAPI}/lambda/functions/${funId}`, JSON.stringify(body), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); + try { + const response = await Axios.get(`${backendAPI}/lambda/requests`, { + proxy: config.proxy, + }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function getLambdaRequests() { - const { backendAPI } = config; + async function getRequestStatus(requestID) { + const { backendAPI } = config; - try { - const response = await Axios.get(`${backendAPI}/lambda/requests`, { - proxy: config.proxy, - }); - - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + try { + const response = await Axios.get(`${backendAPI}/lambda/requests/${requestID}`, { + proxy: config.proxy, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function getRequestStatus(requestID) { - const { backendAPI } = config; + async function cancelLambdaRequest(requestId) { + const { backendAPI } = config; - try { - const response = await Axios.get(`${backendAPI}/lambda/requests/${requestID}`, { - proxy: config.proxy, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + try { + await Axios.delete(`${backendAPI}/lambda/requests/${requestId}`, { + method: 'DELETE', + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function cancelLambdaRequest(requestId) { - const { backendAPI } = config; + function predictorStatus(projectId) { + const { backendAPI } = config; - try { - await Axios.delete(`${backendAPI}/lambda/requests/${requestId}`, { - method: 'DELETE', - }); - } catch (errorData) { - throw generateError(errorData); + return new Promise((resolve, reject) => { + async function request() { + try { + const response = await Axios.get(`${backendAPI}/predict/status`, { + params: { + project: projectId, + }, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } } - } - function predictorStatus(projectId) { + const timeoutCallback = async () => { + let data = null; + try { + data = await request(); + if (data.status === 'queued') { + setTimeout(timeoutCallback, 1000); + } else if (data.status === 'done') { + resolve(data); + } else { + throw new Error(`Unknown status was received "${data.status}"`); + } + } catch (error) { + reject(error); + } + }; + + setTimeout(timeoutCallback); + }); + } + + function predictAnnotations(taskId, frame) { + return new Promise((resolve, reject) => { const { backendAPI } = config; - return new Promise((resolve, reject) => { - async function request() { - try { - const response = await Axios.get(`${backendAPI}/predict/status`, { - params: { - project: projectId, - }, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + async function request() { + try { + const response = await Axios.get(`${backendAPI}/predict/frame`, { + params: { + task: taskId, + frame, + }, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - const timeoutCallback = async () => { - let data = null; - try { - data = await request(); - if (data.status === 'queued') { - setTimeout(timeoutCallback, 1000); - } else if (data.status === 'done') { - resolve(data); - } else { - throw new Error(`Unknown status was received "${data.status}"`); - } - } catch (error) { - reject(error); + const timeoutCallback = async () => { + let data = null; + try { + data = await request(); + if (data.status === 'queued') { + setTimeout(timeoutCallback, 1000); + } else if (data.status === 'done') { + predictAnnotations.latestRequest.fetching = false; + resolve(data.annotation); + } else { + throw new Error(`Unknown status was received "${data.status}"`); } - }; + } catch (error) { + predictAnnotations.latestRequest.fetching = false; + reject(error); + } + }; + const closureId = Date.now(); + predictAnnotations.latestRequest.id = closureId; + const predicate = () => !predictAnnotations.latestRequest.fetching || + predictAnnotations.latestRequest.id !== closureId; + if (predictAnnotations.latestRequest.fetching) { + waitFor(5, predicate).then(() => { + if (predictAnnotations.latestRequest.id !== closureId) { + resolve(null); + } else { + predictAnnotations.latestRequest.fetching = true; + setTimeout(timeoutCallback); + } + }); + } else { + predictAnnotations.latestRequest.fetching = true; setTimeout(timeoutCallback); + } + }); + } + + predictAnnotations.latestRequest = { + fetching: false, + id: null, + }; + + async function installedApps() { + const { backendAPI } = config; + try { + const response = await Axios.get(`${backendAPI}/server/plugins`, { + proxy: config.proxy, }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - function predictAnnotations(taskId, frame) { - return new Promise((resolve, reject) => { - const { backendAPI } = config; + async function createCloudStorage(storageDetail) { + const { backendAPI } = config; - async function request() { - try { - const response = await Axios.get(`${backendAPI}/predict/frame`, { - params: { - task: taskId, - frame, - }, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } - } + const storageDetailData = prepareData(storageDetail); + try { + const response = await Axios.post(`${backendAPI}/cloudstorages`, storageDetailData, { + proxy: config.proxy, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } + } - const timeoutCallback = async () => { - let data = null; - try { - data = await request(); - if (data.status === 'queued') { - setTimeout(timeoutCallback, 1000); - } else if (data.status === 'done') { - predictAnnotations.latestRequest.fetching = false; - resolve(data.annotation); - } else { - throw new Error(`Unknown status was received "${data.status}"`); - } - } catch (error) { - predictAnnotations.latestRequest.fetching = false; - reject(error); - } - }; - - const closureId = Date.now(); - predictAnnotations.latestRequest.id = closureId; - const predicate = () => !predictAnnotations.latestRequest.fetching || - predictAnnotations.latestRequest.id !== closureId; - if (predictAnnotations.latestRequest.fetching) { - waitFor(5, predicate).then(() => { - if (predictAnnotations.latestRequest.id !== closureId) { - resolve(null); - } else { - predictAnnotations.latestRequest.fetching = true; - setTimeout(timeoutCallback); - } - }); - } else { - predictAnnotations.latestRequest.fetching = true; - setTimeout(timeoutCallback); - } + async function updateCloudStorage(id, storageDetail) { + const { backendAPI } = config; + + const storageDetailData = prepareData(storageDetail); + try { + await Axios.patch(`${backendAPI}/cloudstorages/${id}`, storageDetailData, { + proxy: config.proxy, }); + } catch (errorData) { + throw generateError(errorData); } + } - predictAnnotations.latestRequest = { - fetching: false, - id: null, - }; + async function getCloudStorages(filter = {}) { + const { backendAPI } = config; - async function installedApps() { - const { backendAPI } = config; - try { - const response = await Axios.get(`${backendAPI}/server/plugins`, { - proxy: config.proxy, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + let response = null; + try { + response = await Axios.get(`${backendAPI}/cloudstorages`, { + proxy: config.proxy, + params: filter, + page_size: 12, + }); + } catch (errorData) { + throw generateError(errorData); } - async function createCloudStorage(storageDetail) { - const { backendAPI } = config; + response.data.results.count = response.data.count; + return response.data.results; + } - const storageDetailData = prepareData(storageDetail); - try { - const response = await Axios.post(`${backendAPI}/cloudstorages`, storageDetailData, { - proxy: config.proxy, - }); - return response.data; - } catch (errorData) { - throw generateError(errorData); - } + async function getCloudStorageContent(id, manifestPath) { + const { backendAPI } = config; + + let response = null; + try { + const url = `${backendAPI}/cloudstorages/${id}/content${ + manifestPath ? `?manifest_path=${manifestPath}` : '' + }`; + response = await Axios.get(url, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function updateCloudStorage(id, storageDetail) { - const { backendAPI } = config; + return response.data; + } - const storageDetailData = prepareData(storageDetail); - try { - await Axios.patch(`${backendAPI}/cloudstorages/${id}`, storageDetailData, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getCloudStoragePreview(id) { + const { backendAPI } = config; + + let response = null; + try { + const url = `${backendAPI}/cloudstorages/${id}/preview`; + response = await workerAxios.get(url, { + params: enableOrganization(), + proxy: config.proxy, + responseType: 'arraybuffer', + }); + } catch (errorData) { + throw generateError({ + message: '', + response: { + ...errorData.response, + data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)), + }, + }); } - async function getCloudStorages(filter = {}) { - const { backendAPI } = config; + return new Blob([new Uint8Array(response)]); + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/cloudstorages`, { - proxy: config.proxy, - params: filter, - page_size: 12, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getCloudStorageStatus(id) { + const { backendAPI } = config; - response.data.results.count = response.data.count; - return response.data.results; + let response = null; + try { + const url = `${backendAPI}/cloudstorages/${id}/status`; + response = await Axios.get(url, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getCloudStorageContent(id, manifestPath) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - const url = `${backendAPI}/cloudstorages/${id}/content${ - manifestPath ? `?manifest_path=${manifestPath}` : '' - }`; - response = await Axios.get(url, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteCloudStorage(id) { + const { backendAPI } = config; - return response.data; + try { + await Axios.delete(`${backendAPI}/cloudstorages/${id}`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function getCloudStoragePreview(id) { - const { backendAPI } = config; - - let response = null; - try { - const url = `${backendAPI}/cloudstorages/${id}/preview`; - response = await workerAxios.get(url, { - params: enableOrganization(), - proxy: config.proxy, - responseType: 'arraybuffer', - }); - } catch (errorData) { - throw generateError({ - message: '', - response: { - ...errorData.response, - data: String.fromCharCode.apply(null, new Uint8Array(errorData.response.data)), - }, - }); - } + async function getOrganizations() { + const { backendAPI } = config; - return new Blob([new Uint8Array(response)]); + let response = null; + try { + response = await Axios.get(`${backendAPI}/organizations`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getCloudStorageStatus(id) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - const url = `${backendAPI}/cloudstorages/${id}/status`; - response = await Axios.get(url, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function createOrganization(data) { + const { backendAPI } = config; - return response.data; + let response = null; + try { + response = await Axios.post(`${backendAPI}/organizations`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function deleteCloudStorage(id) { - const { backendAPI } = config; + return response.data; + } - try { - await Axios.delete(`${backendAPI}/cloudstorages/${id}`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function updateOrganization(id, data) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.patch(`${backendAPI}/organizations/${id}`, JSON.stringify(data), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } - async function getOrganizations() { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.get(`${backendAPI}/organizations`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteOrganization(id) { + const { backendAPI } = config; - return response.data; + try { + await Axios.delete(`${backendAPI}/organizations/${id}`, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function createOrganization(data) { - const { backendAPI } = config; + async function getOrganizationMembers(orgSlug, page, pageSize, filters = {}) { + const { backendAPI } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/memberships`, { + proxy: config.proxy, + params: { + ...filters, + org: orgSlug, + page, + page_size: pageSize, + }, + }); + } catch (errorData) { + throw generateError(errorData); + } - let response = null; - try { - response = await Axios.post(`${backendAPI}/organizations`, JSON.stringify(data), { + return response.data; + } + + async function inviteOrganizationMembers(orgId, data) { + const { backendAPI } = config; + try { + await Axios.post( + `${backendAPI}/invitations`, + { + ...data, + organization: orgId, + }, + { proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + }, + ); + } catch (errorData) { + throw generateError(errorData); + } + } - return response.data; + async function updateOrganizationMembership(membershipId, data) { + const { backendAPI } = config; + let response = null; + try { + response = await Axios.patch( + `${backendAPI}/memberships/${membershipId}`, + { + ...data, + }, + { + proxy: config.proxy, + }, + ); + } catch (errorData) { + throw generateError(errorData); } - async function updateOrganization(id, data) { - const { backendAPI } = config; + return response.data; + } - let response = null; - try { - response = await Axios.patch(`${backendAPI}/organizations/${id}`, JSON.stringify(data), { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function deleteOrganizationMembership(membershipId) { + const { backendAPI } = config; - return response.data; + try { + await Axios.delete(`${backendAPI}/memberships/${membershipId}`, { + proxy: config.proxy, + }); + } catch (errorData) { + throw generateError(errorData); } + } - async function deleteOrganization(id) { - const { backendAPI } = config; + async function getMembershipInvitation(id) { + const { backendAPI } = config; - try { - await Axios.delete(`${backendAPI}/organizations/${id}`, { - proxy: config.proxy, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + let response = null; + try { + response = await Axios.get(`${backendAPI}/invitations/${id}`, { + proxy: config.proxy, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function getOrganizationMembers(orgSlug, page, pageSize, filters = {}) { - const { backendAPI } = config; - - let response = null; - try { - response = await Axios.get(`${backendAPI}/memberships`, { - proxy: config.proxy, - params: { - ...filters, - org: orgSlug, - page, - page_size: pageSize, - }, - }); - } catch (errorData) { - throw generateError(errorData); - } + async function getWebhookDelivery(webhookID: number, deliveryID: number): Promise { + const params = enableOrganization(); + const { backendAPI } = config; + try { + const response = await Axios.get(`${backendAPI}/webhooks/${webhookID}/deliveries/${deliveryID}`, { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, + }); return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function inviteOrganizationMembers(orgId, data) { - const { backendAPI } = config; - try { - await Axios.post( - `${backendAPI}/invitations`, - { - ...data, - organization: orgId, - }, - { - proxy: config.proxy, - }, - ); - } catch (errorData) { - throw generateError(errorData); - } + async function getWebhooks(filter, pageSize = 10): Promise { + const params = enableOrganization(); + const { backendAPI } = config; + + try { + const response = await Axios.get(`${backendAPI}/webhooks`, { + proxy: config.proxy, + params: { + ...params, + ...filter, + page_size: pageSize, + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + + response.data.results.count = response.data.count; + return response.data.results; + } catch (errorData) { + throw generateError(errorData); } + } - async function updateOrganizationMembership(membershipId, data) { - const { backendAPI } = config; - let response = null; - try { - response = await Axios.patch( - `${backendAPI}/memberships/${membershipId}`, - { - ...data, - }, - { - proxy: config.proxy, - }, - ); - } catch (errorData) { - throw generateError(errorData); - } + async function createWebhook(webhookData: any): Promise { + const params = enableOrganization(); + const { backendAPI } = config; + try { + const response = await Axios.post(`${backendAPI}/webhooks`, JSON.stringify(webhookData), { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, + }); return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function deleteOrganizationMembership(membershipId) { - const { backendAPI } = config; + async function updateWebhook(webhookID: number, webhookData: any): Promise { + const params = enableOrganization(); + const { backendAPI } = config; - try { - await Axios.delete(`${backendAPI}/memberships/${membershipId}`, { + try { + const response = await Axios + .patch(`${backendAPI}/webhooks/${webhookID}`, JSON.stringify(webhookData), { proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, }); - } catch (errorData) { - throw generateError(errorData); - } + return response.data; + } catch (errorData) { + throw generateError(errorData); } + } - async function getMembershipInvitation(id) { - const { backendAPI } = config; + async function deleteWebhook(webhookID: number): Promise { + const params = enableOrganization(); + const { backendAPI } = config; - let response = null; - try { - response = await Axios.get(`${backendAPI}/invitations/${id}`, { - proxy: config.proxy, - }); - } catch (errorData) { - throw generateError(errorData); - } + try { + await Axios.delete(`${backendAPI}/webhooks/${webhookID}`, { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } - return response.data; + async function pingWebhook(webhookID: number): Promise { + const params = enableOrganization(); + const { backendAPI } = config; + + async function waitPingDelivery(deliveryID: number): Promise { + return new Promise((resolve) => { + async function checkStatus(): Promise { + const delivery = await getWebhookDelivery(webhookID, deliveryID); + if (delivery.status_code) { + resolve(delivery); + } else { + setTimeout(checkStatus, 1000); + } + } + setTimeout(checkStatus, 1000); + }); } - Object.defineProperties( - this, - Object.freeze({ - server: { - value: Object.freeze({ - about, - share, - formats, - exception, - login, - logout, - changePassword, - requestPasswordReset, - resetPassword, - authorized, - register, - request: serverRequest, - userAgreements, - installedApps, - }), - writable: false, + try { + const response = await Axios.post(`${backendAPI}/webhooks/${webhookID}/ping`, { + proxy: config.proxy, + params, + headers: { + 'Content-Type': 'application/json', }, + }); - projects: { - value: Object.freeze({ - get: getProjects, - searchNames: searchProjectNames, - save: saveProject, - create: createProject, - delete: deleteProject, - exportDataset: exportDataset('projects'), - backupProject, - restoreProject, - importDataset, - }), - writable: false, - }, + const deliveryID = response.data.id; + const delivery = await waitPingDelivery(deliveryID); + return delivery; + } catch (errorData) { + throw generateError(errorData); + } + } - tasks: { - value: Object.freeze({ - get: getTasks, - save: saveTask, - create: createTask, - delete: deleteTask, - exportDataset: exportDataset('tasks'), - export: exportTask, - import: importTask, - }), - writable: false, - }, + async function receiveWebhookEvents(type: WebhookSourceType): Promise { + const { backendAPI } = config; - jobs: { - value: Object.freeze({ - get: getJobs, - save: saveJob, - }), - writable: false, + try { + const response = await Axios.get(`${backendAPI}/webhooks/events`, { + proxy: config.proxy, + params: { + type, }, - - users: { - value: Object.freeze({ - get: getUsers, - self: getSelf, - }), - writable: false, + headers: { + 'Content-Type': 'application/json', }, + }); + return response.data.events; + } catch (errorData) { + throw generateError(errorData); + } + } - frames: { - value: Object.freeze({ - getData, - getMeta, - saveMeta, - getPreview, - getImageContext, - }), - writable: false, - }, + Object.defineProperties( + this, + Object.freeze({ + server: { + value: Object.freeze({ + about, + share, + formats, + exception, + login, + logout, + changePassword, + requestPasswordReset, + resetPassword, + authorized, + register, + request: serverRequest, + userAgreements, + installedApps, + }), + writable: false, + }, - annotations: { - value: Object.freeze({ - updateAnnotations, - getAnnotations, - dumpAnnotations, - uploadAnnotations, - }), - writable: false, - }, + projects: { + value: Object.freeze({ + get: getProjects, + searchNames: searchProjectNames, + save: saveProject, + create: createProject, + delete: deleteProject, + exportDataset: exportDataset('projects'), + backup: backupProject, + restore: restoreProject, + importDataset, + }), + writable: false, + }, - logs: { - value: Object.freeze({ - save: saveLogs, - }), - writable: false, - }, + tasks: { + value: Object.freeze({ + get: getTasks, + save: saveTask, + create: createTask, + delete: deleteTask, + exportDataset: exportDataset('tasks'), + backup: backupTask, + restore: restoreTask, + }), + writable: false, + }, - lambda: { - value: Object.freeze({ - list: getLambdaFunctions, - status: getRequestStatus, - requests: getLambdaRequests, - run: runLambdaRequest, - call: callLambdaFunction, - cancel: cancelLambdaRequest, - }), - writable: false, - }, + jobs: { + value: Object.freeze({ + get: getJobs, + save: saveJob, + exportDataset: exportDataset('jobs'), + }), + writable: false, + }, - issues: { - value: Object.freeze({ - create: createIssue, - update: updateIssue, - get: getJobIssues, - delete: deleteIssue, - }), - writable: false, - }, + users: { + value: Object.freeze({ + get: getUsers, + self: getSelf, + }), + writable: false, + }, - comments: { - value: Object.freeze({ - create: createComment, - }), - writable: false, - }, + frames: { + value: Object.freeze({ + getData, + getMeta, + saveMeta, + getPreview, + getImageContext, + }), + writable: false, + }, - predictor: { - value: Object.freeze({ - status: predictorStatus, - predict: predictAnnotations, - }), - writable: false, - }, + annotations: { + value: Object.freeze({ + updateAnnotations, + getAnnotations, + dumpAnnotations, + uploadAnnotations, + }), + writable: false, + }, - cloudStorages: { - value: Object.freeze({ - get: getCloudStorages, - getContent: getCloudStorageContent, - getPreview: getCloudStoragePreview, - getStatus: getCloudStorageStatus, - create: createCloudStorage, - delete: deleteCloudStorage, - update: updateCloudStorage, - }), - writable: false, - }, + logs: { + value: Object.freeze({ + save: saveLogs, + }), + writable: false, + }, - organizations: { - value: Object.freeze({ - get: getOrganizations, - create: createOrganization, - update: updateOrganization, - members: getOrganizationMembers, - invitation: getMembershipInvitation, - delete: deleteOrganization, - invite: inviteOrganizationMembers, - updateMembership: updateOrganizationMembership, - deleteMembership: deleteOrganizationMembership, - }), - writable: false, - }, - }), - ); - } + lambda: { + value: Object.freeze({ + list: getLambdaFunctions, + status: getRequestStatus, + requests: getLambdaRequests, + run: runLambdaRequest, + call: callLambdaFunction, + cancel: cancelLambdaRequest, + }), + writable: false, + }, + + issues: { + value: Object.freeze({ + create: createIssue, + update: updateIssue, + get: getJobIssues, + delete: deleteIssue, + }), + writable: false, + }, + + comments: { + value: Object.freeze({ + create: createComment, + }), + writable: false, + }, + + predictor: { + value: Object.freeze({ + status: predictorStatus, + predict: predictAnnotations, + }), + writable: false, + }, + + cloudStorages: { + value: Object.freeze({ + get: getCloudStorages, + getContent: getCloudStorageContent, + getPreview: getCloudStoragePreview, + getStatus: getCloudStorageStatus, + create: createCloudStorage, + delete: deleteCloudStorage, + update: updateCloudStorage, + }), + writable: false, + }, + + organizations: { + value: Object.freeze({ + get: getOrganizations, + create: createOrganization, + update: updateOrganization, + members: getOrganizationMembers, + invitation: getMembershipInvitation, + delete: deleteOrganization, + invite: inviteOrganizationMembers, + updateMembership: updateOrganizationMembership, + deleteMembership: deleteOrganizationMembership, + }), + writable: false, + }, + + webhooks: { + value: Object.freeze({ + get: getWebhooks, + create: createWebhook, + update: updateWebhook, + delete: deleteWebhook, + ping: pingWebhook, + events: receiveWebhookEvents, + }), + writable: false, + }, + }), + ); } +} - const serverProxy = new ServerProxy(); - module.exports = serverProxy; -})(); +const serverProxy = new ServerProxy(); +export default serverProxy; diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 79a8eac03d88..262fd8476b11 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -1,2682 +1,2757 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - const PluginRegistry = require('./plugins').default; - const loggerStorage = require('./logger-storage'); - const serverProxy = require('./server-proxy'); - const { - getFrame, - deleteFrame, - restoreFrame, - getRanges, - getPreview, - clear: clearFrames, - findNotDeletedFrame, - getContextImage, - patchMeta, - getDeletedFrames, - } = require('./frames'); - const { ArgumentError, DataError } = require('./exceptions'); - const { - JobStage, JobState, HistoryActions, - } = require('./enums'); - const { Label } = require('./labels'); - const User = require('./user'); - const Issue = require('./issue'); - const { FieldUpdateTrigger, checkObjectType } = require('./common'); - - function buildDuplicatedAPI(prototype) { - Object.defineProperties(prototype, { - annotations: Object.freeze({ - value: { - async upload(file, loader) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.upload, - file, - loader, - ); - return result; - }, +import { StorageLocation } from './enums'; +import { Storage } from './storage'; + +const PluginRegistry = require('./plugins').default; +const loggerStorage = require('./logger-storage').default; +const serverProxy = require('./server-proxy').default; +const { + getFrame, + deleteFrame, + restoreFrame, + getRanges, + getPreview, + clear: clearFrames, + findNotDeletedFrame, + getContextImage, + patchMeta, + getDeletedFrames, +} = require('./frames'); +const { ArgumentError, DataError } = require('./exceptions'); +const { + JobStage, JobState, HistoryActions, +} = require('./enums'); +const { Label } = require('./labels'); +const User = require('./user').default; +const Issue = require('./issue').default; +const { FieldUpdateTrigger, checkObjectType } = require('./common'); + +function buildDuplicatedAPI(prototype) { + Object.defineProperties(prototype, { + annotations: Object.freeze({ + value: { + async upload(format: string, useDefaultLocation: boolean, sourceStorage: Storage, file: File | string) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.upload, + format, + useDefaultLocation, + sourceStorage, + file, + ); + return result; + }, - async save(onUpdate) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.save, onUpdate); - return result; - }, + async save(onUpdate) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.save, onUpdate); + return result; + }, - async clear( - reload = false, startframe = undefined, endframe = undefined, delTrackKeyframesOnly = true, - ) { - const result = await PluginRegistry.apiWrapper.call( - this, prototype.annotations.clear, reload, startframe, endframe, delTrackKeyframesOnly, - ); - return result; - }, + async clear( + reload = false, startframe = undefined, endframe = undefined, delTrackKeyframesOnly = true, + ) { + const result = await PluginRegistry.apiWrapper.call( + this, prototype.annotations.clear, reload, startframe, endframe, delTrackKeyframesOnly, + ); + return result; + }, - async statistics() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.statistics); - return result; - }, + async statistics() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.statistics); + return result; + }, - async put(arrayOfObjects = []) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.put, - arrayOfObjects, - ); - return result; - }, + async put(arrayOfObjects = []) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.put, + arrayOfObjects, + ); + return result; + }, - async get(frame, allTracks = false, filters = []) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.get, - frame, - allTracks, - filters, - ); - return result; - }, + async get(frame, allTracks = false, filters = []) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.get, + frame, + allTracks, + filters, + ); + return result; + }, - async search(filters, frameFrom, frameTo) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.search, - filters, - frameFrom, - frameTo, - ); - return result; - }, + async search(filters, frameFrom, frameTo) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.search, + filters, + frameFrom, + frameTo, + ); + return result; + }, - async searchEmpty(frameFrom, frameTo) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.searchEmpty, - frameFrom, - frameTo, - ); - return result; - }, + async searchEmpty(frameFrom, frameTo) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.searchEmpty, + frameFrom, + frameTo, + ); + return result; + }, - async select(objectStates, x, y) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.select, - objectStates, - x, - y, - ); - return result; - }, + async select(objectStates, x, y) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.select, + objectStates, + x, + y, + ); + return result; + }, - async merge(objectStates) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.merge, - objectStates, - ); - return result; - }, + async merge(objectStates) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.merge, + objectStates, + ); + return result; + }, - async split(objectState, frame) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.split, - objectState, - frame, - ); - return result; - }, + async split(objectState, frame) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.split, + objectState, + frame, + ); + return result; + }, - async group(objectStates, reset = false) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.group, - objectStates, - reset, - ); - return result; - }, + async group(objectStates, reset = false) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.group, + objectStates, + reset, + ); + return result; + }, - async import(data) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.import, data); - return result; - }, + async import(data) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.import, data); + return result; + }, - async export() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.export); - return result; - }, + async export() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.annotations.export); + return result; + }, - async exportDataset(format, saveImages, customName = '') { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.annotations.exportDataset, - format, - saveImages, - customName, - ); - return result; - }, + async exportDataset( + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + customName?: string, + ) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.annotations.exportDataset, + format, + saveImages, + useDefaultSettings, + targetStorage, + customName, + ); + return result; + }, - hasUnsavedChanges() { - const result = prototype.annotations.hasUnsavedChanges.implementation.call(this); - return result; - }, + hasUnsavedChanges() { + const result = prototype.annotations.hasUnsavedChanges.implementation.call(this); + return result; }, - writable: true, - }), - frames: Object.freeze({ - value: { - async get(frame, isPlaying = false, step = 1) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.frames.get, - frame, - isPlaying, - step, - ); - return result; - }, - async delete(frame) { - await PluginRegistry.apiWrapper.call( - this, - prototype.frames.delete, - frame, - ); - }, - async restore(frame) { - await PluginRegistry.apiWrapper.call( - this, - prototype.frames.restore, - frame, - ); - }, - async save() { - await PluginRegistry.apiWrapper.call( - this, - prototype.frames.save, - ); - }, - async ranges() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.ranges); - return result; - }, - async preview() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.preview); - return result; - }, - async search(filters, frameFrom, frameTo) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.frames.search, - filters, - frameFrom, - frameTo, - ); - return result; - }, - async contextImage(frameId) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.frames.contextImage, - frameId, - ); - return result; - }, + }, + writable: true, + }), + frames: Object.freeze({ + value: { + async get(frame, isPlaying = false, step = 1) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.frames.get, + frame, + isPlaying, + step, + ); + return result; }, - writable: true, - }), - logger: Object.freeze({ - value: { - async log(logType, payload = {}, wait = false) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.logger.log, - logType, - payload, - wait, - ); - return result; - }, + async delete(frame) { + await PluginRegistry.apiWrapper.call( + this, + prototype.frames.delete, + frame, + ); }, - writable: true, - }), - actions: Object.freeze({ - value: { - async undo(count = 1) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.undo, count); - return result; - }, - async redo(count = 1) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.redo, count); - return result; - }, - async freeze(frozen) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.freeze, frozen); - return result; - }, - async clear() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.clear); - return result; - }, - async get() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.get); - return result; - }, + async restore(frame) { + await PluginRegistry.apiWrapper.call( + this, + prototype.frames.restore, + frame, + ); }, - writable: true, - }), - events: Object.freeze({ - value: { - async subscribe(evType, callback) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.events.subscribe, - evType, - callback, - ); - return result; - }, - async unsubscribe(evType, callback = null) { - const result = await PluginRegistry.apiWrapper.call( - this, - prototype.events.unsubscribe, - evType, - callback, - ); - return result; - }, + async save() { + await PluginRegistry.apiWrapper.call( + this, + prototype.frames.save, + ); }, - writable: true, - }), - predictor: Object.freeze({ - value: { - async status() { - const result = await PluginRegistry.apiWrapper.call(this, prototype.predictor.status); - return result; - }, - async predict(frame) { - const result = await PluginRegistry.apiWrapper.call(this, prototype.predictor.predict, frame); - return result; - }, + async ranges() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.ranges); + return result; }, - writable: true, - }), - }); - } - - /** - * Base abstract class for Task and Job. It contains common members. - * @hideconstructor - * @virtual - */ - class Session { - constructor() { - /** - * An interaction with annotations - * @namespace annotations - * @memberof Session - */ - /** - * Upload annotations from a dump file - * You need upload annotations from a server again after successful executing - * @method upload - * @memberof Session.annotations - * @param {File} annotations - a file with annotations - * @param {module:API.cvat.classes.Loader} loader - a loader - * which will be used to upload - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - /** - * Save all changes in annotations on a server - * Objects which hadn't been saved on a server before, - * get a serverID after saving. But received object states aren't updated. - * So, after successful saving it's recommended to update them manually - * (call the annotations.get() again) - * @method save - * @memberof Session.annotations - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @instance - * @async - * @param {function} [onUpdate] saving can be long. - * This callback can be used to notify a user about current progress - * Its argument is a text string - */ - /** - * Remove all annotations and optionally reinitialize it - * @method clear - * @memberof Session.annotations - * @param {boolean} [reload = false] reset all changes and - * reinitialize annotations by data from a server - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} - * @instance - * @async - */ - /** - * Collect short statistics about a task or a job. - * @method statistics - * @memberof Session.annotations - * @returns {module:API.cvat.classes.Statistics} statistics object - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Create new objects from one-frame states - * After successful adding you need to update object states on a frame - * @method put - * @memberof Session.annotations - * @param {module:API.cvat.classes.ObjectState[]} data - * @returns {number[]} identificators of added objects - * array of objects on the specific frame - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.DataError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Get annotations for a specific frame - *
    Filter supports following operators: - * ==, !=, >, >=, <, <=, ~= and (), |, & for grouping. - *
    Filter supports properties: - * width, height, label, serverID, clientID, type, shape, occluded - *
    All prop values are case-sensitive. CVAT uses json queries for search. - *
    Examples: - *
      - *
    • label=="car" | label==["road sign"]
    • - *
    • width >= height
    • - *
    • attr["Attribute 1"] == attr["Attribute 2"]
    • - *
    • type=="track" & shape="rectangle"
    • - *
    • clientID == 50
    • - *
    • (label=="car" & attr["parked"]==true) - * | (label=="pedestrian" & width > 150)
    • - *
    • (( label==["car \"mazda\""]) & - * (attr["sunglass ( help ) es"]==true | - * (width > 150 | height > 150 & (clientID == serverID)))))
    • - *
    - * If you have double quotes in your query string, - * please escape them using back slash: \" - * @method get - * @param {number} frame get objects from the frame - * @param {boolean} allTracks show all tracks - * even if they are outside and not keyframe - * @param {any[]} [filters = []] - * get only objects that satisfied to specific filters - * @returns {module:API.cvat.classes.ObjectState[]} - * @memberof Session.annotations - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Find a frame in the range [from, to] - * that contains at least one object satisfied to a filter - * @method search - * @memberof Session.annotations - * @param {ObjectFilter} [filter = []] filter - * @param {number} from lower bound of a search - * @param {number} to upper bound of a search - * @returns {number|null} a frame that contains objects according to the filter - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Find the nearest empty frame without any annotations - * @method searchEmpty - * @memberof Session.annotations - * @param {number} from lower bound of a search - * @param {number} to upper bound of a search - * @returns {number|null} a empty frame according boundaries - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Select shape under a cursor by using minimal distance - * between a cursor and a shape edge or a shape point - * For closed shapes a cursor is placed inside a shape - * @method select - * @memberof Session.annotations - * @param {module:API.cvat.classes.ObjectState[]} objectStates - * objects which can be selected - * @param {float} x horizontal coordinate - * @param {float} y vertical coordinate - * @returns {Object} - * a pair of {state: ObjectState, distance: number} for selected object. - * Pair values can be null if there aren't any sutisfied objects - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Method unites several shapes and tracks into the one - * All shapes must be the same (rectangle, polygon, etc) - * All labels must be the same - * After successful merge you need to update object states on a frame - * @method merge - * @memberof Session.annotations - * @param {module:API.cvat.classes.ObjectState[]} objectStates - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Method splits a track into two parts - * (start frame: previous frame), (frame, last frame) - * After successful split you need to update object states on a frame - * @method split - * @memberof Session.annotations - * @param {module:API.cvat.classes.ObjectState} objectState - * @param {number} frame - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Method creates a new group and put all passed objects into it - * After successful split you need to update object states on a frame - * @method group - * @memberof Session.annotations - * @param {module:API.cvat.classes.ObjectState[]} objectStates - * @param {boolean} reset pass "true" to reset group value (set it to 0) - * @returns {number} an ID of created group - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Method indicates if there are any changes in - * annotations which haven't been saved on a server - *
    This function cannot be wrapped with a plugin - * @method hasUnsavedChanges - * @memberof Session.annotations - * @returns {boolean} - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - */ - /** - * - * Import raw data in a collection - * @method import - * @memberof Session.annotations - * @param {Object} data - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * - * Export a collection as a row data - * @method export - * @memberof Session.annotations - * @returns {Object} data - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Export as a dataset. - * Method builds a dataset in the specified format. - * @method exportDataset - * @memberof Session.annotations - * @param {module:String} format - a format - * @returns {string} An URL to the dataset file - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Namespace is used for an interaction with frames - * @namespace frames - * @memberof Session - */ - /** - * Get frame by its number - * @method get - * @memberof Session.frames - * @param {number} frame number of frame which you want to get - * @returns {module:API.cvat.classes.FrameData} - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.DataError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - /** - * @typedef {Object} FrameSearchFilters - * @property {boolean} notDeleted if true will search for non-deleted frames - * @property {number} offset defines frame step during search - /** - * Find frame that match the condition - * @method search - * @memberof Session.frames - * @param {FrameSearchFilters} filters filters to search frame for - * @param {number} from lower bound of a search - * @param {number} to upper bound of a search - * @returns {number|null} a non-deleted frame according boundaries - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Delete frame from the job - * @method delete - * @memberof Session.frames - * @param {number} frame number of frame which you want to delete - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Restore frame from the job - * @method delete - * @memberof Session.frames - * @param {number} frame number of frame which you want to restore - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Save any changes in frames if some of them were deleted/restored - * @method save - * @memberof Session.frames - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Get the first frame of a task for preview - * @method preview - * @memberof Session.frames - * @returns {string} - jpeg encoded image - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - /** - * Returns the ranges of cached frames - * @method ranges - * @memberof Session.frames - * @returns {Array.} - * @instance - * @async - */ - /** - * Namespace is used for an interaction with logs - * @namespace logger - * @memberof Session - */ - /** - * Create a log and add it to a log collection
    - * Durable logs will be added after "close" method is called for them
    - * The fields "task_id" and "job_id" automatically added when add logs - * through a task or a job
    - * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
    - * Payload of ignored logs are shallowly combined to previous logs of the same type - * @method log - * @memberof Session.logger - * @param {module:API.cvat.enums.LogType | string} type - log type - * @param {Object} [payload = {}] - any other data that will be appended to the log - * @param {boolean} [wait = false] - specifies if log is durable - * @returns {module:API.cvat.classes.Log} - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - /** - * Namespace is used for an interaction with actions - * @namespace actions - * @memberof Session - */ - /** - * @typedef {Object} HistoryActions - * @property {string[]} [undo] - array of possible actions to undo - * @property {string[]} [redo] - array of possible actions to redo - * @global - */ - /** - * Make undo - * @method undo - * @memberof Session.actions - * @param {number} [count=1] number of actions to undo - * @returns {number[]} Array of affected objects - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Make redo - * @method redo - * @memberof Session.actions - * @param {number} [count=1] number of actions to redo - * @returns {number[]} Array of affected objects - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Freeze history (do not save new actions) - * @method freeze - * @memberof Session.actions - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Remove all actions from history - * @method clear - * @memberof Session.actions - * @throws {module:API.cvat.exceptions.PluginError} - * @instance - * @async - */ - /** - * Get actions - * @method get - * @memberof Session.actions - * @returns {HistoryActions} - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @returns {Array.>} - * array of pairs [action name, frame number] - * @instance - * @async - */ - /** - * Namespace is used for an interaction with events - * @namespace events - * @memberof Session - */ - /** - * Subscribe on an event - * @method subscribe - * @memberof Session.events - * @param {module:API.cvat.enums.EventType} type - event type - * @param {functions} callback - function which will be called on event - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * Unsubscribe from an event. If callback is not provided, - * all callbacks will be removed from subscribers for the event - * @method unsubscribe - * @memberof Session.events - * @param {module:API.cvat.enums.EventType} type - event type - * @param {functions} [callback = null] - function which is called on event - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @instance - * @async - */ - /** - * @typedef {Object} PredictorStatus - * @property {string} message - message for a user to be displayed somewhere - * @property {number} projectScore - model accuracy - * @global - */ - /** - * Namespace is used for an interaction with events - * @namespace predictor - * @memberof Session - */ - /** - * Subscribe to updates of a ML model binded to the project - * @method status - * @memberof Session.predictor - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @returns {PredictorStatus} - * @instance - * @async - */ - /** - * Get predictions from a ML model binded to the project - * @method predict - * @memberof Session.predictor - * @param {number} frame - number of frame to inference - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} - * @throws {module:API.cvat.exceptions.DataError} - * @returns {object[] | null} annotations - * @instance - * @async - */ - } - } - - /** - * Class representing a job. - * @memberof module:API.cvat.classes - * @hideconstructor - * @extends Session - */ - class Job extends Session { - constructor(initialData) { - super(); - const data = { - id: undefined, - assignee: null, - stage: undefined, - state: undefined, - start_frame: undefined, - stop_frame: undefined, - project_id: null, - task_id: undefined, - labels: undefined, - dimension: undefined, - data_compressed_chunk_type: undefined, - data_chunk_size: undefined, - bug_tracker: null, - mode: undefined, - }; - - const updateTrigger = new FieldUpdateTrigger(); - - for (const property in data) { - if (Object.prototype.hasOwnProperty.call(data, property)) { - if (property in initialData) { - data[property] = initialData[property]; - } - - if (data[property] === undefined) { - throw new ArgumentError(`Job field "${property}" was not initialized`); - } - } - } - - if (data.assignee) data.assignee = new User(data.assignee); - if (Array.isArray(initialData.labels)) { - data.labels = initialData.labels.map((labelData) => { - // can be already wrapped to the class - // when create this job from Task constructor - if (labelData instanceof Label) { - return labelData; - } - - return new Label(labelData); - }).filter((label) => !label.hasParent); - } else { - throw new Error('Job labels must be an array'); - } - - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - id: { - get: () => data.id, - }, - /** - * Instance of a user who is responsible for the job annotations - * @name assignee - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Job - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - assignee: { - get: () => data.assignee, - set: (assignee) => { - if (assignee !== null && !(assignee instanceof User)) { - throw new ArgumentError('Value must be a user instance'); - } - updateTrigger.update('assignee'); - data.assignee = assignee; - }, - }, - /** - * @name stage - * @type {module:API.cvat.enums.JobStage} - * @memberof module:API.cvat.classes.Job - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - stage: { - get: () => data.stage, - set: (stage) => { - const type = JobStage; - let valueInEnum = false; - for (const value in type) { - if (type[value] === stage) { - valueInEnum = true; - break; - } - } - - if (!valueInEnum) { - throw new ArgumentError( - 'Value must be a value from the enumeration cvat.enums.JobStage', - ); - } - - updateTrigger.update('stage'); - data.stage = stage; - }, - }, - /** - * @name state - * @type {module:API.cvat.enums.JobState} - * @memberof module:API.cvat.classes.Job - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - state: { - get: () => data.state, - set: (state) => { - const type = JobState; - let valueInEnum = false; - for (const value in type) { - if (type[value] === state) { - valueInEnum = true; - break; - } - } - - if (!valueInEnum) { - throw new ArgumentError( - 'Value must be a value from the enumeration cvat.enums.JobState', - ); - } - - updateTrigger.update('state'); - data.state = state; - }, - }, - /** - * @name startFrame - * @type {number} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - startFrame: { - get: () => data.start_frame, - }, - /** - * @name stopFrame - * @type {number} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - stopFrame: { - get: () => data.stop_frame, - }, - /** - * @name projectId - * @type {number|null} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - projectId: { - get: () => data.project_id, - }, - /** - * @name taskId - * @type {number} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - taskId: { - get: () => data.task_id, - }, - /** - * @name labels - * @type {module:API.cvat.classes.Label[]} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - labels: { - get: () => data.labels.filter((_label) => !_label.deleted), - }, - /** - * @name dimension - * @type {module:API.cvat.enums.DimensionType} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - dimension: { - get: () => data.dimension, - }, - /** - * @name dataChunkSize - * @type {number} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - dataChunkSize: { - get: () => data.data_chunk_size, - set: (chunkSize) => { - if (typeof chunkSize !== 'number' || chunkSize < 1) { - throw new ArgumentError( - `Chunk size value must be a positive number. But value ${chunkSize} has been got.`, - ); - } - - data.data_chunk_size = chunkSize; - }, - }, - /** - * @name dataChunkSize - * @type {string} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - dataChunkType: { - get: () => data.data_compressed_chunk_type, - }, - /** - * @name mode - * @type {string} - * @memberof module:API.cvat.classes.Job - * @readonly - * @instance - */ - mode: { - get: () => data.mode, - }, - /** - * @name bugTracker - * @type {string|null} - * @memberof module:API.cvat.classes.Job - * @instance - * @readonly - */ - bugTracker: { - get: () => data.bug_tracker, - }, - _updateTrigger: { - get: () => updateTrigger, - }, - }), - ); - - // When we call a function, for example: task.annotations.get() - // In the method get we lose the task context - // So, we need return it - this.annotations = { - get: Object.getPrototypeOf(this).annotations.get.bind(this), - put: Object.getPrototypeOf(this).annotations.put.bind(this), - save: Object.getPrototypeOf(this).annotations.save.bind(this), - merge: Object.getPrototypeOf(this).annotations.merge.bind(this), - split: Object.getPrototypeOf(this).annotations.split.bind(this), - group: Object.getPrototypeOf(this).annotations.group.bind(this), - clear: Object.getPrototypeOf(this).annotations.clear.bind(this), - search: Object.getPrototypeOf(this).annotations.search.bind(this), - searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), - upload: Object.getPrototypeOf(this).annotations.upload.bind(this), - select: Object.getPrototypeOf(this).annotations.select.bind(this), - import: Object.getPrototypeOf(this).annotations.import.bind(this), - export: Object.getPrototypeOf(this).annotations.export.bind(this), - statistics: Object.getPrototypeOf(this).annotations.statistics.bind(this), - hasUnsavedChanges: Object.getPrototypeOf(this).annotations.hasUnsavedChanges.bind(this), - exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), - }; - - this.actions = { - undo: Object.getPrototypeOf(this).actions.undo.bind(this), - redo: Object.getPrototypeOf(this).actions.redo.bind(this), - freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), - clear: Object.getPrototypeOf(this).actions.clear.bind(this), - get: Object.getPrototypeOf(this).actions.get.bind(this), - }; - - this.frames = { - get: Object.getPrototypeOf(this).frames.get.bind(this), - delete: Object.getPrototypeOf(this).frames.delete.bind(this), - restore: Object.getPrototypeOf(this).frames.restore.bind(this), - save: Object.getPrototypeOf(this).frames.save.bind(this), - ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), - preview: Object.getPrototypeOf(this).frames.preview.bind(this), - search: Object.getPrototypeOf(this).frames.search.bind(this), - contextImage: Object.getPrototypeOf(this).frames.contextImage.bind(this), - }; - - this.logger = { - log: Object.getPrototypeOf(this).logger.log.bind(this), - }; - - this.predictor = { - status: Object.getPrototypeOf(this).predictor.status.bind(this), - predict: Object.getPrototypeOf(this).predictor.predict.bind(this), - }; - } - + async preview() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.frames.preview); + return result; + }, + async search(filters, frameFrom, frameTo) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.frames.search, + filters, + frameFrom, + frameTo, + ); + return result; + }, + async contextImage(frameId) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.frames.contextImage, + frameId, + ); + return result; + }, + }, + writable: true, + }), + logger: Object.freeze({ + value: { + async log(logType, payload = {}, wait = false) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.logger.log, + logType, + payload, + wait, + ); + return result; + }, + }, + writable: true, + }), + actions: Object.freeze({ + value: { + async undo(count = 1) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.undo, count); + return result; + }, + async redo(count = 1) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.redo, count); + return result; + }, + async freeze(frozen) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.freeze, frozen); + return result; + }, + async clear() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.clear); + return result; + }, + async get() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.actions.get); + return result; + }, + }, + writable: true, + }), + events: Object.freeze({ + value: { + async subscribe(evType, callback) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.events.subscribe, + evType, + callback, + ); + return result; + }, + async unsubscribe(evType, callback = null) { + const result = await PluginRegistry.apiWrapper.call( + this, + prototype.events.unsubscribe, + evType, + callback, + ); + return result; + }, + }, + writable: true, + }), + predictor: Object.freeze({ + value: { + async status() { + const result = await PluginRegistry.apiWrapper.call(this, prototype.predictor.status); + return result; + }, + async predict(frame) { + const result = await PluginRegistry.apiWrapper.call(this, prototype.predictor.predict, frame); + return result; + }, + }, + writable: true, + }), + }); +} + +/** + * Base abstract class for Task and Job. It contains common members. + * @hideconstructor + * @virtual + */ +export class Session { + constructor() { + /** + * An interaction with annotations + * @namespace annotations + * @memberof Session + */ + /** + * Upload annotations from a dump file + * You need upload annotations from a server again after successful executing + * @method upload + * @memberof Session.annotations + * @param {File} annotations - a file with annotations + * @param {module:API.cvat.classes.Loader} loader - a loader + * which will be used to upload + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ /** - * Method updates job data like state, stage or assignee + * Save all changes in annotations on a server + * Objects which hadn't been saved on a server before, + * get a serverID after saving. But received object states aren't updated. + * So, after successful saving it's recommended to update them manually + * (call the annotations.get() again) * @method save - * @memberof module:API.cvat.classes.Job - * @readonly + * @memberof Session.annotations + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} * @instance * @async + * @param {function} [onUpdate] saving can be long. + * This callback can be used to notify a user about current progress + * Its argument is a text string + */ + /** + * Remove all annotations and optionally reinitialize it + * @method clear + * @memberof Session.annotations + * @param {boolean} [reload = false] reset all changes and + * reinitialize annotations by data from a server + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} * @throws {module:API.cvat.exceptions.ServerError} + * @instance + * @async + */ + /** + * Collect short statistics about a task or a job. + * @method statistics + * @memberof Session.annotations + * @returns {module:API.cvat.classes.Statistics} statistics object * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async */ - async save() { - const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save); - return result; - } - /** - * Method returns a list of issues for a job - * @method issues - * @memberof module:API.cvat.classes.Job - * @returns {module:API.cvat.classes.Issue[]} - * @readonly + * Create new objects from one-frame states + * After successful adding you need to update object states on a frame + * @method put + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} data + * @returns {number[]} identificators of added objects + * array of objects on the specific frame + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.DataError} + * @throws {module:API.cvat.exceptions.ArgumentError} * @instance * @async - * @throws {module:API.cvat.exceptions.ServerError} + */ + /** + * Get annotations for a specific frame + *
    Filter supports following operators: + * ==, !=, >, >=, <, <=, ~= and (), |, & for grouping. + *
    Filter supports properties: + * width, height, label, serverID, clientID, type, shape, occluded + *
    All prop values are case-sensitive. CVAT uses json queries for search. + *
    Examples: + *
      + *
    • label=="car" | label==["road sign"]
    • + *
    • width >= height
    • + *
    • attr["Attribute 1"] == attr["Attribute 2"]
    • + *
    • type=="track" & shape="rectangle"
    • + *
    • clientID == 50
    • + *
    • (label=="car" & attr["parked"]==true) + * | (label=="pedestrian" & width > 150)
    • + *
    • (( label==["car \"mazda\""]) & + * (attr["sunglass ( help ) es"]==true | + * (width > 150 | height > 150 & (clientID == serverID)))))
    • + *
    + * If you have double quotes in your query string, + * please escape them using back slash: \" + * @method get + * @param {number} frame get objects from the frame + * @param {boolean} allTracks show all tracks + * even if they are outside and not keyframe + * @param {any[]} [filters = []] + * get only objects that satisfied to specific filters + * @returns {module:API.cvat.classes.ObjectState[]} + * @memberof Session.annotations * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async */ - async issues() { - const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.issues); - return result; - } - /** - * Method adds a new issue to a job - * @method openIssue - * @memberof module:API.cvat.classes.Job - * @returns {module:API.cvat.classes.Issue} - * @param {module:API.cvat.classes.Issue} issue - * @param {string} message - * @readonly + * Find a frame in the range [from, to] + * that contains at least one object satisfied to a filter + * @method search + * @memberof Session.annotations + * @param {ObjectFilter} [filter = []] filter + * @param {number} from lower bound of a search + * @param {number} to upper bound of a search + * @returns {number|null} a frame that contains objects according to the filter + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} * @instance * @async + */ + /** + * Find the nearest empty frame without any annotations + * @method searchEmpty + * @memberof Session.annotations + * @param {number} from lower bound of a search + * @param {number} to upper bound of a search + * @returns {number|null} a empty frame according boundaries + * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ArgumentError} - * @throws {module:API.cvat.exceptions.ServerError} + * @instance + * @async + */ + /** + * Select shape under a cursor by using minimal distance + * between a cursor and a shape edge or a shape point + * For closed shapes a cursor is placed inside a shape + * @method select + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} objectStates + * objects which can be selected + * @param {float} x horizontal coordinate + * @param {float} y vertical coordinate + * @returns {Object} + * a pair of {state: ObjectState, distance: number} for selected object. + * Pair values can be null if there aren't any sutisfied objects * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async */ - async openIssue(issue, message) { - const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.openIssue, issue, message); - return result; - } - /** - * Method removes all job related data from the client (annotations, history, etc.) - * @method close - * @returns {module:API.cvat.classes.Job} - * @memberof module:API.cvat.classes.Job - * @readonly + * Method unites several shapes and tracks into the one + * All shapes must be the same (rectangle, polygon, etc) + * All labels must be the same + * After successful merge you need to update object states on a frame + * @method merge + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} objectStates + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance * @async + */ + /** + * Method splits a track into two parts + * (start frame: previous frame), (frame, last frame) + * After successful split you need to update object states on a frame + * @method split + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState} objectState + * @param {number} frame + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} * @instance + * @async + */ + /** + * Method creates a new group and put all passed objects into it + * After successful split you need to update object states on a frame + * @method group + * @memberof Session.annotations + * @param {module:API.cvat.classes.ObjectState[]} objectStates + * @param {boolean} reset pass "true" to reset group value (set it to 0) + * @returns {number} an ID of created group + * @throws {module:API.cvat.exceptions.ArgumentError} * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async */ - async close() { - const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.close); - return result; - } - } - - /** - * Class representing a task - * @memberof module:API.cvat.classes - * @extends Session - */ - class Task extends Session { /** - * In a fact you need use the constructor only if you want to create a task - * @param {object} initialData - Object which is used for initialization - *
    It can contain keys: - *
  • name - *
  • assignee - *
  • bug_tracker - *
  • labels - *
  • segment_size - *
  • overlap + * Method indicates if there are any changes in + * annotations which haven't been saved on a server + *
    This function cannot be wrapped with a plugin + * @method hasUnsavedChanges + * @memberof Session.annotations + * @returns {boolean} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + */ + /** + * + * Import raw data in a collection + * @method import + * @memberof Session.annotations + * @param {Object} data + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ + /** + * + * Export a collection as a row data + * @method export + * @memberof Session.annotations + * @returns {Object} data + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ + /** + * Export as a dataset. + * Method builds a dataset in the specified format. + * @method exportDataset + * @memberof Session.annotations + * @param {module:String} format - a format + * @returns {string} An URL to the dataset file + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ + /** + * Namespace is used for an interaction with frames + * @namespace frames + * @memberof Session + */ + /** + * Get frame by its number + * @method get + * @memberof Session.frames + * @param {number} frame number of frame which you want to get + * @returns {module:API.cvat.classes.FrameData} + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.DataError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + /** + * @typedef {Object} FrameSearchFilters + * @property {boolean} notDeleted if true will search for non-deleted frames + * @property {number} offset defines frame step during search + /** + * Find frame that match the condition + * @method search + * @memberof Session.frames + * @param {FrameSearchFilters} filters filters to search frame for + * @param {number} from lower bound of a search + * @param {number} to upper bound of a search + * @returns {number|null} a non-deleted frame according boundaries + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async + */ + /** + * Delete frame from the job + * @method delete + * @memberof Session.frames + * @param {number} frame number of frame which you want to delete + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ + /** + * Restore frame from the job + * @method delete + * @memberof Session.frames + * @param {number} frame number of frame which you want to restore + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ + /** + * Save any changes in frames if some of them were deleted/restored + * @method save + * @memberof Session.frames + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async */ - constructor(initialData) { - super(); - const data = { - id: undefined, - name: undefined, - project_id: null, - status: undefined, - size: undefined, - mode: undefined, - owner: null, - assignee: null, - created_date: undefined, - updated_date: undefined, - bug_tracker: undefined, - subset: undefined, - overlap: undefined, - segment_size: undefined, - image_quality: undefined, - start_frame: undefined, - stop_frame: undefined, - frame_filter: undefined, - data_chunk_size: undefined, - data_compressed_chunk_type: undefined, - data_original_chunk_type: undefined, - deleted_frames: undefined, - use_zip_chunks: undefined, - use_cache: undefined, - copy_data: undefined, - dimension: undefined, - cloud_storage_id: undefined, - sorting_method: undefined, - }; - - const updateTrigger = new FieldUpdateTrigger(); - - for (const property in data) { - if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { - data[property] = initialData[property]; - } - } - - if (data.assignee) data.assignee = new User(data.assignee); - if (data.owner) data.owner = new User(data.owner); - - data.labels = []; - data.jobs = []; - data.files = Object.freeze({ - server_files: [], - client_files: [], - remote_files: [], - }); - - if (Array.isArray(initialData.labels)) { - data.labels = initialData.labels - .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); - } - - if (Array.isArray(initialData.segments)) { - for (const segment of initialData.segments) { - if (Array.isArray(segment.jobs)) { - for (const job of segment.jobs) { - const jobInstance = new Job({ - url: job.url, - id: job.id, - assignee: job.assignee, - state: job.state, - stage: job.stage, - start_frame: segment.start_frame, - stop_frame: segment.stop_frame, - // following fields also returned when doing API request /jobs/ - // here we know them from task and append to constructor - task_id: data.id, - project_id: data.project_id, - labels: data.labels, - bug_tracker: data.bug_tracker, - mode: data.mode, - dimension: data.dimension, - data_compressed_chunk_type: data.data_compressed_chunk_type, - data_chunk_size: data.data_chunk_size, - }); - - data.jobs.push(jobInstance); - } - } - } - } - - Object.defineProperties( - this, - Object.freeze({ - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - id: { - get: () => data.id, - }, - /** - * @name name - * @type {string} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - name: { - get: () => data.name, - set: (value) => { - if (!value.trim().length) { - throw new ArgumentError('Value must not be empty'); - } - updateTrigger.update('name'); - data.name = value; - }, - }, - /** - * @name projectId - * @type {number|null} - * @memberof module:API.cvat.classes.Task - * @instance - */ - projectId: { - get: () => data.project_id, - set: (projectId) => { - if (!Number.isInteger(projectId) || projectId <= 0) { - throw new ArgumentError('Value must be a positive integer'); - } - - updateTrigger.update('projectId'); - data.project_id = projectId; - }, - }, - /** - * @name status - * @type {module:API.cvat.enums.TaskStatus} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - status: { - get: () => data.status, - }, - /** - * @name size - * @type {number} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - size: { - get: () => data.size, - }, - /** - * @name mode - * @type {TaskMode} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - mode: { - get: () => data.mode, - }, - /** - * Instance of a user who has created the task - * @name owner - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - owner: { - get: () => data.owner, - }, - /** - * Instance of a user who is responsible for the task - * @name assignee - * @type {module:API.cvat.classes.User} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - assignee: { - get: () => data.assignee, - set: (assignee) => { - if (assignee !== null && !(assignee instanceof User)) { - throw new ArgumentError('Value must be a user instance'); - } - updateTrigger.update('assignee'); - data.assignee = assignee; - }, - }, - /** - * @name createdDate - * @type {string} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - createdDate: { - get: () => data.created_date, - }, - /** - * @name updatedDate - * @type {string} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - updatedDate: { - get: () => data.updated_date, - }, - /** - * @name bugTracker - * @type {string} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - bugTracker: { - get: () => data.bug_tracker, - set: (tracker) => { - if (typeof tracker !== 'string') { - throw new ArgumentError( - `Subset value must be a string. But ${typeof tracker} has been got.`, - ); - } - - updateTrigger.update('bugTracker'); - data.bug_tracker = tracker; - }, - }, - /** - * @name subset - * @type {string} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exception.ArgumentError} - */ - subset: { - get: () => data.subset, - set: (subset) => { - if (typeof subset !== 'string') { - throw new ArgumentError( - `Subset value must be a string. But ${typeof subset} has been got.`, - ); - } - - updateTrigger.update('subset'); - data.subset = subset; - }, - }, - /** - * @name overlap - * @type {number} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - overlap: { - get: () => data.overlap, - set: (overlap) => { - if (!Number.isInteger(overlap) || overlap < 0) { - throw new ArgumentError('Value must be a non negative integer'); - } - data.overlap = overlap; - }, - }, - /** - * @name segmentSize - * @type {number} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - segmentSize: { - get: () => data.segment_size, - set: (segment) => { - if (!Number.isInteger(segment) || segment < 0) { - throw new ArgumentError('Value must be a positive integer'); - } - data.segment_size = segment; - }, - }, - /** - * @name imageQuality - * @type {number} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - imageQuality: { - get: () => data.image_quality, - set: (quality) => { - if (!Number.isInteger(quality) || quality < 0) { - throw new ArgumentError('Value must be a positive integer'); - } - data.image_quality = quality; - }, - }, - /** - * @name useZipChunks - * @type {boolean} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - useZipChunks: { - get: () => data.use_zip_chunks, - set: (useZipChunks) => { - if (typeof useZipChunks !== 'boolean') { - throw new ArgumentError('Value must be a boolean'); - } - data.use_zip_chunks = useZipChunks; - }, - }, - /** - * @name useCache - * @type {boolean} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - useCache: { - get: () => data.use_cache, - set: (useCache) => { - if (typeof useCache !== 'boolean') { - throw new ArgumentError('Value must be a boolean'); - } - data.use_cache = useCache; - }, - }, - /** - * @name copyData - * @type {boolean} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - copyData: { - get: () => data.copy_data, - set: (copyData) => { - if (typeof copyData !== 'boolean') { - throw new ArgumentError('Value must be a boolean'); - } - data.copy_data = copyData; - }, - }, - /** - * @name labels - * @type {module:API.cvat.classes.Label[]} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - labels: { - get: () => data.labels.filter((_label) => !_label.deleted), - set: (labels) => { - if (!Array.isArray(labels)) { - throw new ArgumentError('Value must be an array of Labels'); - } - - for (const label of labels) { - if (!(label instanceof Label)) { - throw new ArgumentError( - `Each array value must be an instance of Label. ${typeof label} was found`, - ); - } - } - - const IDs = labels.map((_label) => _label.id); - const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); - deletedLabels.forEach((_label) => { - _label.deleted = true; - }); - - updateTrigger.update('labels'); - data.labels = [...deletedLabels, ...labels]; - }, - }, - /** - * @name jobs - * @type {module:API.cvat.classes.Job[]} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - jobs: { - get: () => [...data.jobs], - }, - /** - * List of files from shared resource or list of cloud storage files - * @name serverFiles - * @type {string[]} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - serverFiles: { - get: () => Array.from(data.files.server_files), - set: (serverFiles: string[]) => { - if (!Array.isArray(serverFiles)) { - throw new ArgumentError( - `Value must be an array. But ${typeof serverFiles} has been got.`, - ); - } - - for (const value of serverFiles) { - if (typeof value !== 'string') { - throw new ArgumentError( - `Array values must be a string. But ${typeof value} has been got.`, - ); - } - } - - for (const file of serverFiles) { - data.files.server_files.push(file); - } - }, - }, - /** - * List of files from client host - * @name clientFiles - * @type {File[]} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - clientFiles: { - get: () => Array.from(data.files.client_files), - set: (clientFiles: File[]) => { - if (!Array.isArray(clientFiles)) { - throw new ArgumentError( - `Value must be an array. But ${typeof clientFiles} has been got.`, - ); - } - - for (const value of clientFiles) { - if (!(value instanceof File)) { - throw new ArgumentError( - `Array values must be a File. But ${value.constructor.name} has been got.`, - ); - } - } - - for (const file of clientFiles) { - data.files.client_files.push(file); - } - }, - }, - /** - * List of files from remote host - * @name remoteFiles - * @type {string[]} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - remoteFiles: { - get: () => Array.from(data.files.remote_files), - set: (remoteFiles) => { - if (!Array.isArray(remoteFiles)) { - throw new ArgumentError( - `Value must be an array. But ${typeof remoteFiles} has been got.`, - ); - } - - for (const value of remoteFiles) { - if (typeof value !== 'string') { - throw new ArgumentError( - `Array values must be a string. But ${typeof value} has been got.`, - ); - } - } - - for (const file of remoteFiles) { - data.files.remote_files.push(file); - } - }, - }, - /** - * The first frame of a video to annotation - * @name startFrame - * @type {number} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - startFrame: { - get: () => data.start_frame, - set: (frame) => { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError('Value must be a not negative integer'); - } - data.start_frame = frame; - }, - }, - /** - * The last frame of a video to annotation - * @name stopFrame - * @type {number} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - stopFrame: { - get: () => data.stop_frame, - set: (frame) => { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError('Value must be a not negative integer'); - } - data.stop_frame = frame; - }, - }, - /** - * Filter to ignore some frames during task creation - * @name frameFilter - * @type {string} - * @memberof module:API.cvat.classes.Task - * @instance - * @throws {module:API.cvat.exceptions.ArgumentError} - */ - frameFilter: { - get: () => data.frame_filter, - set: (filter) => { - if (typeof filter !== 'string') { - throw new ArgumentError( - `Filter value must be a string. But ${typeof filter} has been got.`, - ); - } - - data.frame_filter = filter; - }, - }, - dataChunkSize: { - get: () => data.data_chunk_size, - set: (chunkSize) => { - if (typeof chunkSize !== 'number' || chunkSize < 1) { - throw new ArgumentError( - `Chunk size value must be a positive number. But value ${chunkSize} has been got.`, - ); - } - - data.data_chunk_size = chunkSize; - }, - }, - dataChunkType: { - get: () => data.data_compressed_chunk_type, - }, - /** - * @name dimension - * @type {module:API.cvat.enums.DimensionType} - * @memberof module:API.cvat.classes.Task - * @readonly - * @instance - */ - dimension: { - get: () => data.dimension, - }, - /** - * @name cloudStorageId - * @type {integer|null} - * @memberof module:API.cvat.classes.Task - * @instance - */ - cloudStorageId: { - get: () => data.cloud_storage_id, - }, - sortingMethod: { - /** - * @name sortingMethod - * @type {module:API.cvat.enums.SortingMethod} - * @memberof module:API.cvat.classes.Task - * @instance - * @readonly - */ - get: () => data.sorting_method, - }, - _internalData: { - get: () => data, - }, - _updateTrigger: { - get: () => updateTrigger, - }, - }), - ); - - // When we call a function, for example: task.annotations.get() - // In the method get we lose the task context - // So, we need return it - this.annotations = { - get: Object.getPrototypeOf(this).annotations.get.bind(this), - put: Object.getPrototypeOf(this).annotations.put.bind(this), - save: Object.getPrototypeOf(this).annotations.save.bind(this), - merge: Object.getPrototypeOf(this).annotations.merge.bind(this), - split: Object.getPrototypeOf(this).annotations.split.bind(this), - group: Object.getPrototypeOf(this).annotations.group.bind(this), - clear: Object.getPrototypeOf(this).annotations.clear.bind(this), - search: Object.getPrototypeOf(this).annotations.search.bind(this), - searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), - upload: Object.getPrototypeOf(this).annotations.upload.bind(this), - select: Object.getPrototypeOf(this).annotations.select.bind(this), - import: Object.getPrototypeOf(this).annotations.import.bind(this), - export: Object.getPrototypeOf(this).annotations.export.bind(this), - statistics: Object.getPrototypeOf(this).annotations.statistics.bind(this), - hasUnsavedChanges: Object.getPrototypeOf(this).annotations.hasUnsavedChanges.bind(this), - exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), - }; - - this.actions = { - undo: Object.getPrototypeOf(this).actions.undo.bind(this), - redo: Object.getPrototypeOf(this).actions.redo.bind(this), - freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), - clear: Object.getPrototypeOf(this).actions.clear.bind(this), - get: Object.getPrototypeOf(this).actions.get.bind(this), - }; - - this.frames = { - get: Object.getPrototypeOf(this).frames.get.bind(this), - delete: Object.getPrototypeOf(this).frames.delete.bind(this), - restore: Object.getPrototypeOf(this).frames.restore.bind(this), - save: Object.getPrototypeOf(this).frames.save.bind(this), - ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), - preview: Object.getPrototypeOf(this).frames.preview.bind(this), - contextImage: Object.getPrototypeOf(this).frames.contextImage.bind(this), - search: Object.getPrototypeOf(this).frames.search.bind(this), - }; - - this.logger = { - log: Object.getPrototypeOf(this).logger.log.bind(this), - }; - - this.predictor = { - status: Object.getPrototypeOf(this).predictor.status.bind(this), - predict: Object.getPrototypeOf(this).predictor.predict.bind(this), - }; - } - /** - * Method removes all task related data from the client (annotations, history, etc.) - * @method close - * @returns {module:API.cvat.classes.Task} - * @memberof module:API.cvat.classes.Task - * @readonly + * Get the first frame of a task for preview + * @method preview + * @memberof Session.frames + * @returns {string} - jpeg encoded image + * @instance * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + /** + * Returns the ranges of cached frames + * @method ranges + * @memberof Session.frames + * @returns {Array.} + * @instance + * @async + */ + /** + * Namespace is used for an interaction with logs + * @namespace logger + * @memberof Session + */ + /** + * Create a log and add it to a log collection
    + * Durable logs will be added after "close" method is called for them
    + * The fields "task_id" and "job_id" automatically added when add logs + * through a task or a job
    + * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
    + * Payload of ignored logs are shallowly combined to previous logs of the same type + * @method log + * @memberof Session.logger + * @param {module:API.cvat.enums.LogType | string} type - log type + * @param {Object} [payload = {}] - any other data that will be appended to the log + * @param {boolean} [wait = false] - specifies if log is durable + * @returns {module:API.cvat.classes.Log} * @instance + * @async * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + /** + * Namespace is used for an interaction with actions + * @namespace actions + * @memberof Session + */ + /** + * @typedef {Object} HistoryActions + * @property {string[]} [undo] - array of possible actions to undo + * @property {string[]} [redo] - array of possible actions to redo + * @global */ - async close() { - const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.close); - return result; - } - /** - * Method updates data of a created task or creates new task from scratch - * @method save - * @returns {module:API.cvat.classes.Task} - * @memberof module:API.cvat.classes.Task - * @param {function} [onUpdate] - the function which is used only if task hasn't - * been created yet. It called in order to notify about creation status. - * It receives the string parameter which is a status message - * @readonly + * Make undo + * @method undo + * @memberof Session.actions + * @param {number} [count=1] number of actions to undo + * @returns {number[]} Array of affected objects + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} * @instance * @async - * @throws {module:API.cvat.exceptions.ServerError} + */ + /** + * Make redo + * @method redo + * @memberof Session.actions + * @param {number} [count=1] number of actions to redo + * @returns {number[]} Array of affected objects * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async */ - async save(onUpdate = () => {}) { - const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.save, onUpdate); - return result; - } - /** - * Method deletes a task from a server - * @method delete - * @memberof module:API.cvat.classes.Task - * @readonly + * Freeze history (do not save new actions) + * @method freeze + * @memberof Session.actions + * @throws {module:API.cvat.exceptions.PluginError} * @instance * @async - * @throws {module:API.cvat.exceptions.ServerError} + */ + /** + * Remove all actions from history + * @method clear + * @memberof Session.actions * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async */ - async delete() { - const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.delete); - return result; - } - /** - * Method makes a backup of a task - * @method export - * @memberof module:API.cvat.classes.Task - * @readonly + * Get actions + * @method get + * @memberof Session.actions + * @returns {HistoryActions} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @returns {Array.>} + * array of pairs [action name, frame number] * @instance * @async - * @throws {module:API.cvat.exceptions.ServerError} + */ + /** + * Namespace is used for an interaction with events + * @namespace events + * @memberof Session + */ + /** + * Subscribe on an event + * @method subscribe + * @memberof Session.events + * @param {module:API.cvat.enums.EventType} type - event type + * @param {functions} callback - function which will be called on event * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @instance + * @async */ - async export() { - const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.export); - return result; - } - /** - * Method imports a task from a backup - * @method import - * @memberof module:API.cvat.classes.Task - * @readonly + * Unsubscribe from an event. If callback is not provided, + * all callbacks will be removed from subscribers for the event + * @method unsubscribe + * @memberof Session.events + * @param {module:API.cvat.enums.EventType} type - event type + * @param {functions} [callback = null] - function which is called on event + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} * @instance * @async + */ + /** + * @typedef {Object} PredictorStatus + * @property {string} message - message for a user to be displayed somewhere + * @property {number} projectScore - model accuracy + * @global + */ + /** + * Namespace is used for an interaction with events + * @namespace predictor + * @memberof Session + */ + /** + * Subscribe to updates of a ML model binded to the project + * @method status + * @memberof Session.predictor + * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ServerError} + * @returns {PredictorStatus} + * @instance + * @async + */ + /** + * Get predictions from a ML model binded to the project + * @method predict + * @memberof Session.predictor + * @param {number} frame - number of frame to inference * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.DataError} + * @returns {object[] | null} annotations + * @instance + * @async */ - static async import(file) { - const result = await PluginRegistry.apiWrapper.call(this, Task.import, file); - return result; - } } +} + +/** + * Class representing a job. + * @memberof module:API.cvat.classes + * @hideconstructor + * @extends Session + */ +export class Job extends Session { + constructor(initialData) { + super(); + const data = { + id: undefined, + assignee: null, + stage: undefined, + state: undefined, + start_frame: undefined, + stop_frame: undefined, + project_id: null, + task_id: undefined, + labels: undefined, + dimension: undefined, + data_compressed_chunk_type: undefined, + data_chunk_size: undefined, + bug_tracker: null, + mode: undefined, + }; - module.exports = { - Job, - Task, - }; + const updateTrigger = new FieldUpdateTrigger(); - const { - getAnnotations, - putAnnotations, - saveAnnotations, - hasUnsavedChanges, - searchAnnotations, - searchEmptyFrame, - mergeAnnotations, - splitAnnotations, - groupAnnotations, - clearAnnotations, - selectObject, - annotationsStatistics, - uploadAnnotations, - importAnnotations, - exportAnnotations, - exportDataset, - undoActions, - redoActions, - freezeHistory, - clearActions, - getActions, - closeSession, - getHistory, - } = require('./annotations'); - - buildDuplicatedAPI(Job.prototype); - buildDuplicatedAPI(Task.prototype); - - Job.prototype.save.implementation = async function () { - if (this.id) { - const jobData = this._updateTrigger.getUpdated(this); - if (jobData.assignee) { - jobData.assignee = jobData.assignee.id; - } + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property)) { + if (property in initialData) { + data[property] = initialData[property]; + } - const data = await serverProxy.jobs.save(this.id, jobData); - this._updateTrigger.reset(); - return new Job(data); + if (data[property] === undefined) { + throw new ArgumentError(`Job field "${property}" was not initialized`); + } + } } - throw new ArgumentError('Could not save job without id'); - }; + if (data.assignee) data.assignee = new User(data.assignee); + if (Array.isArray(initialData.labels)) { + data.labels = initialData.labels.map((labelData) => { + // can be already wrapped to the class + // when create this job from Task constructor + if (labelData instanceof Label) { + return labelData; + } - Job.prototype.issues.implementation = async function () { - const result = await serverProxy.issues.get(this.id); - return result.map((issue) => new Issue(issue)); - }; + return new Label(labelData); + }).filter((label) => !label.hasParent); + } else { + throw new Error('Job labels must be an array'); + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * Instance of a user who is responsible for the job annotations + * @name assignee + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Job + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + assignee: { + get: () => data.assignee, + set: (assignee) => { + if (assignee !== null && !(assignee instanceof User)) { + throw new ArgumentError('Value must be a user instance'); + } + updateTrigger.update('assignee'); + data.assignee = assignee; + }, + }, + /** + * @name stage + * @type {module:API.cvat.enums.JobStage} + * @memberof module:API.cvat.classes.Job + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + stage: { + get: () => data.stage, + set: (stage) => { + const type = JobStage; + let valueInEnum = false; + for (const value in type) { + if (type[value] === stage) { + valueInEnum = true; + break; + } + } - Job.prototype.openIssue.implementation = async function (issue, message) { - checkObjectType('issue', issue, null, Issue); - checkObjectType('message', message, 'string'); - const result = await serverProxy.issues.create({ - ...issue.serialize(), - message, - }); - return new Issue(result); - }; + if (!valueInEnum) { + throw new ArgumentError( + 'Value must be a value from the enumeration cvat.enums.JobStage', + ); + } - Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); - } + updateTrigger.update('stage'); + data.stage = stage; + }, + }, + /** + * @name state + * @type {module:API.cvat.enums.JobState} + * @memberof module:API.cvat.classes.Job + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + state: { + get: () => data.state, + set: (state) => { + const type = JobState; + let valueInEnum = false; + for (const value in type) { + if (type[value] === state) { + valueInEnum = true; + break; + } + } - if (frame < this.startFrame || frame > this.stopFrame) { - throw new ArgumentError(`The frame with number ${frame} is out of the job`); - } + if (!valueInEnum) { + throw new ArgumentError( + 'Value must be a value from the enumeration cvat.enums.JobState', + ); + } - const frameData = await getFrame( - this.id, - this.dataChunkSize, - this.dataChunkType, - this.mode, - frame, - this.startFrame, - this.stopFrame, - isPlaying, - step, - this.dimension, + updateTrigger.update('state'); + data.state = state; + }, + }, + /** + * @name startFrame + * @type {number} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + startFrame: { + get: () => data.start_frame, + }, + /** + * @name stopFrame + * @type {number} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + stopFrame: { + get: () => data.stop_frame, + }, + /** + * @name projectId + * @type {number|null} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + projectId: { + get: () => data.project_id, + }, + /** + * @name taskId + * @type {number} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + taskId: { + get: () => data.task_id, + }, + /** + * @name labels + * @type {module:API.cvat.classes.Label[]} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + labels: { + get: () => data.labels.filter((_label) => !_label.deleted), + }, + /** + * @name dimension + * @type {module:API.cvat.enums.DimensionType} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + dimension: { + get: () => data.dimension, + }, + /** + * @name dataChunkSize + * @type {number} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + dataChunkSize: { + get: () => data.data_chunk_size, + set: (chunkSize) => { + if (typeof chunkSize !== 'number' || chunkSize < 1) { + throw new ArgumentError( + `Chunk size value must be a positive number. But value ${chunkSize} has been got.`, + ); + } + + data.data_chunk_size = chunkSize; + }, + }, + /** + * @name dataChunkSize + * @type {string} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + dataChunkType: { + get: () => data.data_compressed_chunk_type, + }, + /** + * @name mode + * @type {string} + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + */ + mode: { + get: () => data.mode, + }, + /** + * @name bugTracker + * @type {string|null} + * @memberof module:API.cvat.classes.Job + * @instance + * @readonly + */ + bugTracker: { + get: () => data.bug_tracker, + }, + _updateTrigger: { + get: () => updateTrigger, + }, + }), ); - return frameData; - }; - // must be called with task/job context - async function deleteFrameWrapper(jobID, frame) { - const history = getHistory(this); - const redo = async () => { - deleteFrame(jobID, frame); + // When we call a function, for example: task.annotations.get() + // In the method get we lose the task context + // So, we need return it + this.annotations = { + get: Object.getPrototypeOf(this).annotations.get.bind(this), + put: Object.getPrototypeOf(this).annotations.put.bind(this), + save: Object.getPrototypeOf(this).annotations.save.bind(this), + merge: Object.getPrototypeOf(this).annotations.merge.bind(this), + split: Object.getPrototypeOf(this).annotations.split.bind(this), + group: Object.getPrototypeOf(this).annotations.group.bind(this), + clear: Object.getPrototypeOf(this).annotations.clear.bind(this), + search: Object.getPrototypeOf(this).annotations.search.bind(this), + searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), + upload: Object.getPrototypeOf(this).annotations.upload.bind(this), + select: Object.getPrototypeOf(this).annotations.select.bind(this), + import: Object.getPrototypeOf(this).annotations.import.bind(this), + export: Object.getPrototypeOf(this).annotations.export.bind(this), + statistics: Object.getPrototypeOf(this).annotations.statistics.bind(this), + hasUnsavedChanges: Object.getPrototypeOf(this).annotations.hasUnsavedChanges.bind(this), + exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), }; - await redo(); - history.do(HistoryActions.REMOVED_FRAME, async () => { - restoreFrame(jobID, frame); - }, redo, [], frame); - } - - async function restoreFrameWrapper(jobID, frame) { - const history = getHistory(this); - const redo = async () => { - restoreFrame(jobID, frame); + this.actions = { + undo: Object.getPrototypeOf(this).actions.undo.bind(this), + redo: Object.getPrototypeOf(this).actions.redo.bind(this), + freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), + clear: Object.getPrototypeOf(this).actions.clear.bind(this), + get: Object.getPrototypeOf(this).actions.get.bind(this), }; - await redo(); - history.do(HistoryActions.RESTORED_FRAME, async () => { - deleteFrame(jobID, frame); - }, redo, [], frame); - } - - Job.prototype.frames.delete.implementation = async function (frame) { - if (!Number.isInteger(frame)) { - throw new Error(`Frame must be an integer. Got: "${frame}"`); - } + this.frames = { + get: Object.getPrototypeOf(this).frames.get.bind(this), + delete: Object.getPrototypeOf(this).frames.delete.bind(this), + restore: Object.getPrototypeOf(this).frames.restore.bind(this), + save: Object.getPrototypeOf(this).frames.save.bind(this), + ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), + preview: Object.getPrototypeOf(this).frames.preview.bind(this), + search: Object.getPrototypeOf(this).frames.search.bind(this), + contextImage: Object.getPrototypeOf(this).frames.contextImage.bind(this), + }; - if (frame < this.startFrame || frame > this.stopFrame) { - throw new Error('The frame is out of the job'); - } + this.logger = { + log: Object.getPrototypeOf(this).logger.log.bind(this), + }; - await deleteFrameWrapper.call(this, this.id, frame); - }; + this.predictor = { + status: Object.getPrototypeOf(this).predictor.status.bind(this), + predict: Object.getPrototypeOf(this).predictor.predict.bind(this), + }; + } - Job.prototype.frames.restore.implementation = async function (frame) { - if (!Number.isInteger(frame)) { - throw new Error(`Frame must be an integer. Got: "${frame}"`); - } + /** + * Method updates job data like state, stage or assignee + * @method save + * @memberof module:API.cvat.classes.Job + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async save() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.save); + return result; + } - if (frame < this.startFrame || frame > this.stopFrame) { - throw new Error('The frame is out of the job'); - } + /** + * Method returns a list of issues for a job + * @method issues + * @memberof module:API.cvat.classes.Job + * @returns {module:API.cvat.classes.Issue[]} + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async issues() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.issues); + return result; + } - await restoreFrameWrapper.call(this, this.id, frame); - }; + /** + * Method adds a new issue to a job + * @method openIssue + * @memberof module:API.cvat.classes.Job + * @returns {module:API.cvat.classes.Issue} + * @param {module:API.cvat.classes.Issue} issue + * @param {string} message + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ArgumentError} + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async openIssue(issue, message) { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.openIssue, issue, message); + return result; + } - Job.prototype.frames.save.implementation = async function () { - const result = await patchMeta(this.id); + /** + * Method removes all job related data from the client (annotations, history, etc.) + * @method close + * @returns {module:API.cvat.classes.Job} + * @memberof module:API.cvat.classes.Job + * @readonly + * @async + * @instance + * @throws {module:API.cvat.exceptions.PluginError} + */ + async close() { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.close); return result; - }; + } +} + +/** + * Class representing a task + * @memberof module:API.cvat.classes + * @extends Session + */ +export class Task extends Session { + /** + * In a fact you need use the constructor only if you want to create a task + * @param {object} initialData - Object which is used for initialization + *
    It can contain keys: + *
  • name + *
  • assignee + *
  • bug_tracker + *
  • labels + *
  • segment_size + *
  • overlap + */ + constructor(initialData) { + super(); + const data = { + id: undefined, + name: undefined, + project_id: null, + status: undefined, + size: undefined, + mode: undefined, + owner: null, + assignee: null, + created_date: undefined, + updated_date: undefined, + bug_tracker: undefined, + subset: undefined, + overlap: undefined, + segment_size: undefined, + image_quality: undefined, + start_frame: undefined, + stop_frame: undefined, + frame_filter: undefined, + data_chunk_size: undefined, + data_compressed_chunk_type: undefined, + data_original_chunk_type: undefined, + deleted_frames: undefined, + use_zip_chunks: undefined, + use_cache: undefined, + copy_data: undefined, + dimension: undefined, + cloud_storage_id: undefined, + sorting_method: undefined, + source_storage: undefined, + target_storage: undefined, + }; - Job.prototype.frames.ranges.implementation = async function () { - const rangesData = await getRanges(this.id); - return rangesData; - }; + const updateTrigger = new FieldUpdateTrigger(); - Job.prototype.frames.preview.implementation = async function () { - if (this.id === null || this.taskId === null) { - return ''; + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } } - const frameData = await getPreview(this.taskId, this.id); - return frameData; - }; - - Job.prototype.frames.contextImage.implementation = async function (frameId) { - const result = await getContextImage(this.id, frameId); - return result; - }; + if (data.assignee) data.assignee = new User(data.assignee); + if (data.owner) data.owner = new User(data.owner); - Job.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) { - if (typeof filters !== 'object') { - throw new ArgumentError('Filters should be an object'); - } + data.labels = []; + data.jobs = []; + data.files = Object.freeze({ + server_files: [], + client_files: [], + remote_files: [], + }); - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); + if (Array.isArray(initialData.labels)) { + data.labels = initialData.labels + .map((labelData) => new Label(labelData)).filter((label) => !label.hasParent); + } + + if (Array.isArray(initialData.segments)) { + for (const segment of initialData.segments) { + if (Array.isArray(segment.jobs)) { + for (const job of segment.jobs) { + const jobInstance = new Job({ + url: job.url, + id: job.id, + assignee: job.assignee, + state: job.state, + stage: job.stage, + start_frame: segment.start_frame, + stop_frame: segment.stop_frame, + // following fields also returned when doing API request /jobs/ + // here we know them from task and append to constructor + task_id: data.id, + project_id: data.project_id, + labels: data.labels, + bug_tracker: data.bug_tracker, + mode: data.mode, + dimension: data.dimension, + data_compressed_chunk_type: data.data_compressed_chunk_type, + data_chunk_size: data.data_chunk_size, + }); + + data.jobs.push(jobInstance); + } + } + } } - if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { - throw new ArgumentError('The start frame is out of the job'); - } + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + name: { + get: () => data.name, + set: (value) => { + if (!value.trim().length) { + throw new ArgumentError('Value must not be empty'); + } + updateTrigger.update('name'); + data.name = value; + }, + }, + /** + * @name projectId + * @type {number|null} + * @memberof module:API.cvat.classes.Task + * @instance + */ + projectId: { + get: () => data.project_id, + set: (projectId) => { + if (!Number.isInteger(projectId) || projectId <= 0) { + throw new ArgumentError('Value must be a positive integer'); + } - if (frameTo < this.startFrame || frameTo > this.stopFrame) { - throw new ArgumentError('The stop frame is out of the job'); - } - if (filters.notDeleted) { - return findNotDeletedFrame(this.id, frameFrom, frameTo, filters.offset || 1); - } - return null; - }; + updateTrigger.update('projectId'); + data.project_id = projectId; + }, + }, + /** + * @name status + * @type {module:API.cvat.enums.TaskStatus} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + status: { + get: () => data.status, + }, + /** + * @name size + * @type {number} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + size: { + get: () => data.size, + }, + /** + * @name mode + * @type {TaskMode} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + mode: { + get: () => data.mode, + }, + /** + * Instance of a user who has created the task + * @name owner + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + owner: { + get: () => data.owner, + }, + /** + * Instance of a user who is responsible for the task + * @name assignee + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + assignee: { + get: () => data.assignee, + set: (assignee) => { + if (assignee !== null && !(assignee instanceof User)) { + throw new ArgumentError('Value must be a user instance'); + } + updateTrigger.update('assignee'); + data.assignee = assignee; + }, + }, + /** + * @name createdDate + * @type {string} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + createdDate: { + get: () => data.created_date, + }, + /** + * @name updatedDate + * @type {string} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + updatedDate: { + get: () => data.updated_date, + }, + /** + * @name bugTracker + * @type {string} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + bugTracker: { + get: () => data.bug_tracker, + set: (tracker) => { + if (typeof tracker !== 'string') { + throw new ArgumentError( + `Subset value must be a string. But ${typeof tracker} has been got.`, + ); + } - // TODO: Check filter for annotations - Job.prototype.annotations.get.implementation = async function (frame, allTracks, filters) { - if (!Array.isArray(filters)) { - throw new ArgumentError('Filters must be an array'); - } + updateTrigger.update('bugTracker'); + data.bug_tracker = tracker; + }, + }, + /** + * @name subset + * @type {string} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exception.ArgumentError} + */ + subset: { + get: () => data.subset, + set: (subset) => { + if (typeof subset !== 'string') { + throw new ArgumentError( + `Subset value must be a string. But ${typeof subset} has been got.`, + ); + } - if (!Number.isInteger(frame)) { - throw new ArgumentError('The frame argument must be an integer'); - } + updateTrigger.update('subset'); + data.subset = subset; + }, + }, + /** + * @name overlap + * @type {number} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + overlap: { + get: () => data.overlap, + set: (overlap) => { + if (!Number.isInteger(overlap) || overlap < 0) { + throw new ArgumentError('Value must be a non negative integer'); + } + data.overlap = overlap; + }, + }, + /** + * @name segmentSize + * @type {number} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + segmentSize: { + get: () => data.segment_size, + set: (segment) => { + if (!Number.isInteger(segment) || segment < 0) { + throw new ArgumentError('Value must be a positive integer'); + } + data.segment_size = segment; + }, + }, + /** + * @name imageQuality + * @type {number} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + imageQuality: { + get: () => data.image_quality, + set: (quality) => { + if (!Number.isInteger(quality) || quality < 0) { + throw new ArgumentError('Value must be a positive integer'); + } + data.image_quality = quality; + }, + }, + /** + * @name useZipChunks + * @type {boolean} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + useZipChunks: { + get: () => data.use_zip_chunks, + set: (useZipChunks) => { + if (typeof useZipChunks !== 'boolean') { + throw new ArgumentError('Value must be a boolean'); + } + data.use_zip_chunks = useZipChunks; + }, + }, + /** + * @name useCache + * @type {boolean} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + useCache: { + get: () => data.use_cache, + set: (useCache) => { + if (typeof useCache !== 'boolean') { + throw new ArgumentError('Value must be a boolean'); + } + data.use_cache = useCache; + }, + }, + /** + * @name copyData + * @type {boolean} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + copyData: { + get: () => data.copy_data, + set: (copyData) => { + if (typeof copyData !== 'boolean') { + throw new ArgumentError('Value must be a boolean'); + } + data.copy_data = copyData; + }, + }, + /** + * @name labels + * @type {module:API.cvat.classes.Label[]} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + labels: { + get: () => data.labels.filter((_label) => !_label.deleted), + set: (labels) => { + if (!Array.isArray(labels)) { + throw new ArgumentError('Value must be an array of Labels'); + } - if (frame < this.startFrame || frame > this.stopFrame) { - throw new ArgumentError(`Frame ${frame} does not exist in the job`); - } + for (const label of labels) { + if (!(label instanceof Label)) { + throw new ArgumentError( + `Each array value must be an instance of Label. ${typeof label} was found`, + ); + } + } - const annotationsData = await getAnnotations(this, frame, allTracks, filters); - const deletedFrames = await getDeletedFrames('job', this.id); - if (frame in deletedFrames) { - return []; - } + const IDs = labels.map((_label) => _label.id); + const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id)); + deletedLabels.forEach((_label) => { + _label.deleted = true; + }); - return annotationsData; - }; + updateTrigger.update('labels'); + data.labels = [...deletedLabels, ...labels]; + }, + }, + /** + * @name jobs + * @type {module:API.cvat.classes.Job[]} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + jobs: { + get: () => [...data.jobs], + }, + /** + * List of files from shared resource or list of cloud storage files + * @name serverFiles + * @type {string[]} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + serverFiles: { + get: () => [...data.files.server_files], + set: (serverFiles) => { + if (!Array.isArray(serverFiles)) { + throw new ArgumentError( + `Value must be an array. But ${typeof serverFiles} has been got.`, + ); + } - Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { - if (!Array.isArray(filters)) { - throw new ArgumentError('Filters must be an array'); - } + for (const value of serverFiles) { + if (typeof value !== 'string') { + throw new ArgumentError( + `Array values must be a string. But ${typeof value} has been got.`, + ); + } + } - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); - } + Array.prototype.push.apply(data.files.server_files, serverFiles); + }, + }, + /** + * List of files from client host + * @name clientFiles + * @type {File[]} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + clientFiles: { + get: () => [...data.files.client_files], + set: (clientFiles) => { + if (!Array.isArray(clientFiles)) { + throw new ArgumentError( + `Value must be an array. But ${typeof clientFiles} has been got.`, + ); + } - if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { - throw new ArgumentError('The start frame is out of the job'); - } + for (const value of clientFiles) { + if (!(value instanceof File)) { + throw new ArgumentError( + `Array values must be a File. But ${value.constructor.name} has been got.`, + ); + } + } - if (frameTo < this.startFrame || frameTo > this.stopFrame) { - throw new ArgumentError('The stop frame is out of the job'); - } + Array.prototype.push.apply(data.files.client_files, clientFiles); + }, + }, + /** + * List of files from remote host + * @name remoteFiles + * @type {File[]} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + remoteFiles: { + get: () => [...data.files.remote_files], + set: (remoteFiles) => { + if (!Array.isArray(remoteFiles)) { + throw new ArgumentError( + `Value must be an array. But ${typeof remoteFiles} has been got.`, + ); + } - const result = searchAnnotations(this, filters, frameFrom, frameTo); - return result; - }; + for (const value of remoteFiles) { + if (typeof value !== 'string') { + throw new ArgumentError( + `Array values must be a string. But ${typeof value} has been got.`, + ); + } + } - Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); - } + Array.prototype.push.apply(data.files.remote_files, remoteFiles); + }, + }, + /** + * The first frame of a video to annotation + * @name startFrame + * @type {number} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + startFrame: { + get: () => data.start_frame, + set: (frame) => { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError('Value must be a not negative integer'); + } + data.start_frame = frame; + }, + }, + /** + * The last frame of a video to annotation + * @name stopFrame + * @type {number} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + stopFrame: { + get: () => data.stop_frame, + set: (frame) => { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError('Value must be a not negative integer'); + } + data.stop_frame = frame; + }, + }, + /** + * Filter to ignore some frames during task creation + * @name frameFilter + * @type {string} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + frameFilter: { + get: () => data.frame_filter, + set: (filter) => { + if (typeof filter !== 'string') { + throw new ArgumentError( + `Filter value must be a string. But ${typeof filter} has been got.`, + ); + } - if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { - throw new ArgumentError('The start frame is out of the job'); - } + data.frame_filter = filter; + }, + }, + dataChunkSize: { + get: () => data.data_chunk_size, + set: (chunkSize) => { + if (typeof chunkSize !== 'number' || chunkSize < 1) { + throw new ArgumentError( + `Chunk size value must be a positive number. But value ${chunkSize} has been got.`, + ); + } - if (frameTo < this.startFrame || frameTo > this.stopFrame) { - throw new ArgumentError('The stop frame is out of the job'); - } + data.data_chunk_size = chunkSize; + }, + }, + dataChunkType: { + get: () => data.data_compressed_chunk_type, + }, + /** + * @name dimension + * @type {module:API.cvat.enums.DimensionType} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + dimension: { + get: () => data.dimension, + }, + /** + * @name cloudStorageId + * @type {integer|null} + * @memberof module:API.cvat.classes.Task + * @instance + */ + cloudStorageId: { + get: () => data.cloud_storage_id, + }, + sortingMethod: { + /** + * @name sortingMethod + * @type {module:API.cvat.enums.SortingMethod} + * @memberof module:API.cvat.classes.Task + * @instance + * @readonly + */ + get: () => data.sorting_method, + }, + /** + * Source storage for import resources. + * @name sourceStorage + * @type {module:API.cvat.classes.Storage} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + sourceStorage: { + get: () => ( + new Storage({ + location: data.source_storage?.location || StorageLocation.LOCAL, + cloudStorageId: data.source_storage?.cloud_storage_id, + }) + ), + }, + /** + * Target storage for export resources. + * @name targetStorage + * @type {module:API.cvat.classes.Storage} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + targetStorage: { + get: () => ( + new Storage({ + location: data.target_storage?.location || StorageLocation.LOCAL, + cloudStorageId: data.target_storage?.cloud_storage_id, + }) + ), + }, + _internalData: { + get: () => data, + }, + _updateTrigger: { + get: () => updateTrigger, + }, + }), + ); - const result = searchEmptyFrame(this, frameFrom, frameTo); - return result; - }; + // When we call a function, for example: task.annotations.get() + // In the method get we lose the task context + // So, we need return it + this.annotations = { + get: Object.getPrototypeOf(this).annotations.get.bind(this), + put: Object.getPrototypeOf(this).annotations.put.bind(this), + save: Object.getPrototypeOf(this).annotations.save.bind(this), + merge: Object.getPrototypeOf(this).annotations.merge.bind(this), + split: Object.getPrototypeOf(this).annotations.split.bind(this), + group: Object.getPrototypeOf(this).annotations.group.bind(this), + clear: Object.getPrototypeOf(this).annotations.clear.bind(this), + search: Object.getPrototypeOf(this).annotations.search.bind(this), + searchEmpty: Object.getPrototypeOf(this).annotations.searchEmpty.bind(this), + upload: Object.getPrototypeOf(this).annotations.upload.bind(this), + select: Object.getPrototypeOf(this).annotations.select.bind(this), + import: Object.getPrototypeOf(this).annotations.import.bind(this), + export: Object.getPrototypeOf(this).annotations.export.bind(this), + statistics: Object.getPrototypeOf(this).annotations.statistics.bind(this), + hasUnsavedChanges: Object.getPrototypeOf(this).annotations.hasUnsavedChanges.bind(this), + exportDataset: Object.getPrototypeOf(this).annotations.exportDataset.bind(this), + }; - Job.prototype.annotations.save.implementation = async function (onUpdate) { - const result = await saveAnnotations(this, onUpdate); - return result; - }; + this.actions = { + undo: Object.getPrototypeOf(this).actions.undo.bind(this), + redo: Object.getPrototypeOf(this).actions.redo.bind(this), + freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), + clear: Object.getPrototypeOf(this).actions.clear.bind(this), + get: Object.getPrototypeOf(this).actions.get.bind(this), + }; - Job.prototype.annotations.merge.implementation = async function (objectStates) { - const result = await mergeAnnotations(this, objectStates); - return result; - }; + this.frames = { + get: Object.getPrototypeOf(this).frames.get.bind(this), + delete: Object.getPrototypeOf(this).frames.delete.bind(this), + restore: Object.getPrototypeOf(this).frames.restore.bind(this), + save: Object.getPrototypeOf(this).frames.save.bind(this), + ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), + preview: Object.getPrototypeOf(this).frames.preview.bind(this), + contextImage: Object.getPrototypeOf(this).frames.contextImage.bind(this), + search: Object.getPrototypeOf(this).frames.search.bind(this), + }; - Job.prototype.annotations.split.implementation = async function (objectState, frame) { - const result = await splitAnnotations(this, objectState, frame); - return result; - }; + this.logger = { + log: Object.getPrototypeOf(this).logger.log.bind(this), + }; - Job.prototype.annotations.group.implementation = async function (objectStates, reset) { - const result = await groupAnnotations(this, objectStates, reset); - return result; - }; + this.predictor = { + status: Object.getPrototypeOf(this).predictor.status.bind(this), + predict: Object.getPrototypeOf(this).predictor.predict.bind(this), + }; + } - Job.prototype.annotations.hasUnsavedChanges.implementation = function () { - const result = hasUnsavedChanges(this); + /** + * Method removes all task related data from the client (annotations, history, etc.) + * @method close + * @returns {module:API.cvat.classes.Task} + * @memberof module:API.cvat.classes.Task + * @readonly + * @async + * @instance + * @throws {module:API.cvat.exceptions.PluginError} + */ + async close() { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.close); return result; - }; + } - Job.prototype.annotations.clear.implementation = async function ( - reload, startframe, endframe, delTrackKeyframesOnly, - ) { - const result = await clearAnnotations(this, reload, startframe, endframe, delTrackKeyframesOnly); + /** + * Method updates data of a created task or creates new task from scratch + * @method save + * @returns {module:API.cvat.classes.Task} + * @memberof module:API.cvat.classes.Task + * @param {function} [onUpdate] - the function which is used only if task hasn't + * been created yet. It called in order to notify about creation status. + * It receives the string parameter which is a status message + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async save(onUpdate = () => {}) { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.save, onUpdate); return result; - }; + } - Job.prototype.annotations.select.implementation = function (frame, x, y) { - const result = selectObject(this, frame, x, y); + /** + * Method deletes a task from a server + * @method delete + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async delete() { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.delete); return result; - }; + } - Job.prototype.annotations.statistics.implementation = function () { - const result = annotationsStatistics(this); + /** + * Method makes a backup of a task + * @method backup + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async backup(targetStorage: Storage, useDefaultSettings: boolean, fileName?: string) { + const result = await PluginRegistry.apiWrapper.call( + this, + Task.prototype.backup, + targetStorage, + useDefaultSettings, + fileName, + ); return result; - }; + } - Job.prototype.annotations.put.implementation = function (objectStates) { - const result = putAnnotations(this, objectStates); + /** + * Method restores a task from a backup + * @method restore + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + static async restore(storage: Storage, file: File | string) { + const result = await PluginRegistry.apiWrapper.call(this, Task.restore, storage, file); return result; - }; + } +} + +const { + getAnnotations, + putAnnotations, + saveAnnotations, + hasUnsavedChanges, + searchAnnotations, + searchEmptyFrame, + mergeAnnotations, + splitAnnotations, + groupAnnotations, + clearAnnotations, + selectObject, + annotationsStatistics, + importCollection, + exportCollection, + importDataset, + exportDataset, + undoActions, + redoActions, + freezeHistory, + clearActions, + getActions, + closeSession, + getHistory, +} = require('./annotations'); + +buildDuplicatedAPI(Job.prototype); +buildDuplicatedAPI(Task.prototype); + +Job.prototype.save.implementation = async function () { + if (this.id) { + const jobData = this._updateTrigger.getUpdated(this); + if (jobData.assignee) { + jobData.assignee = jobData.assignee.id; + } + + const data = await serverProxy.jobs.save(this.id, jobData); + this._updateTrigger.reset(); + return new Job(data); + } - Job.prototype.annotations.upload.implementation = async function (file, loader) { - const result = await uploadAnnotations(this, file, loader); - return result; - }; + throw new ArgumentError('Could not save job without id'); +}; + +Job.prototype.issues.implementation = async function () { + const result = await serverProxy.issues.get(this.id); + return result.map((issue) => new Issue(issue)); +}; + +Job.prototype.openIssue.implementation = async function (issue, message) { + checkObjectType('issue', issue, null, Issue); + checkObjectType('message', message, 'string'); + const result = await serverProxy.issues.create({ + ...issue.serialize(), + message, + }); + return new Issue(result); +}; + +Job.prototype.frames.get.implementation = async function (frame, isPlaying, step) { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); + } - Job.prototype.annotations.import.implementation = function (data) { - const result = importAnnotations(this, data); - return result; - }; + if (frame < this.startFrame || frame > this.stopFrame) { + throw new ArgumentError(`The frame with number ${frame} is out of the job`); + } - Job.prototype.annotations.export.implementation = function () { - const result = exportAnnotations(this); - return result; - }; + const frameData = await getFrame( + this.id, + this.dataChunkSize, + this.dataChunkType, + this.mode, + frame, + this.startFrame, + this.stopFrame, + isPlaying, + step, + this.dimension, + ); + return frameData; +}; + +// must be called with task/job context +async function deleteFrameWrapper(jobID, frame) { + const history = getHistory(this); + const redo = async () => { + deleteFrame(jobID, frame); + }; + + await redo(); + history.do(HistoryActions.REMOVED_FRAME, async () => { + restoreFrame(jobID, frame); + }, redo, [], frame); +} + +async function restoreFrameWrapper(jobID, frame) { + const history = getHistory(this); + const redo = async () => { + restoreFrame(jobID, frame); + }; + + await redo(); + history.do(HistoryActions.RESTORED_FRAME, async () => { + deleteFrame(jobID, frame); + }, redo, [], frame); +} + +Job.prototype.frames.delete.implementation = async function (frame) { + if (!Number.isInteger(frame)) { + throw new Error(`Frame must be an integer. Got: "${frame}"`); + } - Job.prototype.annotations.exportDataset.implementation = async function (format, saveImages, customName) { - const result = await exportDataset(this, format, customName, saveImages); - return result; - }; + if (frame < this.startFrame || frame > this.stopFrame) { + throw new Error('The frame is out of the job'); + } - Job.prototype.actions.undo.implementation = async function (count) { - const result = await undoActions(this, count); - return result; - }; + await deleteFrameWrapper.call(this, this.id, frame); +}; - Job.prototype.actions.redo.implementation = async function (count) { - const result = await redoActions(this, count); - return result; - }; +Job.prototype.frames.restore.implementation = async function (frame) { + if (!Number.isInteger(frame)) { + throw new Error(`Frame must be an integer. Got: "${frame}"`); + } - Job.prototype.actions.freeze.implementation = function (frozen) { - const result = freezeHistory(this, frozen); - return result; - }; + if (frame < this.startFrame || frame > this.stopFrame) { + throw new Error('The frame is out of the job'); + } - Job.prototype.actions.clear.implementation = function () { - const result = clearActions(this); - return result; - }; + await restoreFrameWrapper.call(this, this.id, frame); +}; - Job.prototype.actions.get.implementation = function () { - const result = getActions(this); - return result; - }; +Job.prototype.frames.save.implementation = async function () { + const result = await patchMeta(this.id); + return result; +}; - Job.prototype.logger.log.implementation = async function (logType, payload, wait) { - const result = await loggerStorage.log(logType, { ...payload, task_id: this.taskId, job_id: this.id }, wait); - return result; - }; +Job.prototype.frames.ranges.implementation = async function () { + const rangesData = await getRanges(this.id); + return rangesData; +}; - Job.prototype.predictor.status.implementation = async function () { - if (!Number.isInteger(this.projectId)) { - throw new DataError('The job must belong to a project to use the feature'); - } +Job.prototype.frames.preview.implementation = async function () { + if (this.id === null || this.taskId === null) { + return ''; + } - const result = await serverProxy.predictor.status(this.projectId); - return { - message: result.message, - progress: result.progress, - projectScore: result.score, - timeRemaining: result.time_remaining, - mediaAmount: result.media_amount, - annotationAmount: result.annotation_amount, - }; - }; + const frameData = await getPreview(this.taskId, this.id); + return frameData; +}; - Job.prototype.predictor.predict.implementation = async function (frame) { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); - } +Job.prototype.frames.contextImage.implementation = async function (frameId) { + const result = await getContextImage(this.id, frameId); + return result; +}; - if (frame < this.startFrame || frame > this.stopFrame) { - throw new ArgumentError(`The frame with number ${frame} is out of the job`); - } +Job.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) { + if (typeof filters !== 'object') { + throw new ArgumentError('Filters should be an object'); + } - if (!Number.isInteger(this.projectId)) { - throw new DataError('The job must belong to a project to use the feature'); - } + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - const result = await serverProxy.predictor.predict(this.taskId, frame); - return result; - }; + if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { + throw new ArgumentError('The start frame is out of the job'); + } - Job.prototype.close.implementation = function closeTask() { - clearFrames(this.id); - closeSession(this); - return this; - }; + if (frameTo < this.startFrame || frameTo > this.stopFrame) { + throw new ArgumentError('The stop frame is out of the job'); + } + if (filters.notDeleted) { + return findNotDeletedFrame(this.id, frameFrom, frameTo, filters.offset || 1); + } + return null; +}; - Task.prototype.close.implementation = function closeTask() { - for (const job of this.jobs) { - clearFrames(job.id); - closeSession(job); - } +// TODO: Check filter for annotations +Job.prototype.annotations.get.implementation = async function (frame, allTracks, filters) { + if (!Array.isArray(filters)) { + throw new ArgumentError('Filters must be an array'); + } - closeSession(this); - return this; - }; + if (!Number.isInteger(frame)) { + throw new ArgumentError('The frame argument must be an integer'); + } - Task.prototype.save.implementation = async function (onUpdate) { - // TODO: Add ability to change an owner and an assignee - if (typeof this.id !== 'undefined') { - // If the task has been already created, we update it - const taskData = this._updateTrigger.getUpdated(this, { - bugTracker: 'bug_tracker', - projectId: 'project_id', - assignee: 'assignee_id', - }); - if (taskData.assignee_id) { - taskData.assignee_id = taskData.assignee_id.id; - } - if (taskData.labels) { - taskData.labels = this._internalData.labels; - taskData.labels = taskData.labels.map((el) => el.toJSON()); - } + if (frame < this.startFrame || frame > this.stopFrame) { + throw new ArgumentError(`Frame ${frame} does not exist in the job`); + } - const data = await serverProxy.tasks.save(this.id, taskData); - this._updateTrigger.reset(); - return new Task(data); - } + const annotationsData = await getAnnotations(this, frame, allTracks, filters); + const deletedFrames = await getDeletedFrames('job', this.id); + if (frame in deletedFrames) { + return []; + } - const taskSpec = { - name: this.name, - labels: this.labels.map((el) => el.toJSON()), - }; + return annotationsData; +}; - if (typeof this.bugTracker !== 'undefined') { - taskSpec.bug_tracker = this.bugTracker; - } - if (typeof this.segmentSize !== 'undefined') { - taskSpec.segment_size = this.segmentSize; - } - if (typeof this.overlap !== 'undefined') { - taskSpec.overlap = this.overlap; - } - if (typeof this.projectId !== 'undefined') { - taskSpec.project_id = this.projectId; - } - if (typeof this.subset !== 'undefined') { - taskSpec.subset = this.subset; - } +Job.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { + if (!Array.isArray(filters)) { + throw new ArgumentError('Filters must be an array'); + } - const taskDataSpec = { - client_files: this.clientFiles, - server_files: this.serverFiles, - remote_files: this.remoteFiles, - image_quality: this.imageQuality, - use_zip_chunks: this.useZipChunks, - use_cache: this.useCache, - sorting_method: this.sortingMethod, - }; + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - if (typeof this.startFrame !== 'undefined') { - taskDataSpec.start_frame = this.startFrame; - } - if (typeof this.stopFrame !== 'undefined') { - taskDataSpec.stop_frame = this.stopFrame; - } - if (typeof this.frameFilter !== 'undefined') { - taskDataSpec.frame_filter = this.frameFilter; - } - if (typeof this.dataChunkSize !== 'undefined') { - taskDataSpec.chunk_size = this.dataChunkSize; - } - if (typeof this.copyData !== 'undefined') { - taskDataSpec.copy_data = this.copyData; - } - if (typeof this.cloudStorageId !== 'undefined') { - taskDataSpec.cloud_storage_id = this.cloudStorageId; - } + if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { + throw new ArgumentError('The start frame is out of the job'); + } - const task = await serverProxy.tasks.create(taskSpec, taskDataSpec, onUpdate); - return new Task(task); - }; + if (frameTo < this.startFrame || frameTo > this.stopFrame) { + throw new ArgumentError('The stop frame is out of the job'); + } - Task.prototype.delete.implementation = async function () { - const result = await serverProxy.tasks.delete(this.id); - return result; - }; + const result = searchAnnotations(this, filters, frameFrom, frameTo); + return result; +}; - Task.prototype.export.implementation = async function () { - const result = await serverProxy.tasks.export(this.id); - return result; - }; +Job.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - Task.import.implementation = async function (file) { - // eslint-disable-next-line no-unsanitized/method - const result = await serverProxy.tasks.import(file); - return result; - }; + if (frameFrom < this.startFrame || frameFrom > this.stopFrame) { + throw new ArgumentError('The start frame is out of the job'); + } - Task.prototype.frames.get.implementation = async function (frame, isPlaying, step) { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); - } + if (frameTo < this.startFrame || frameTo > this.stopFrame) { + throw new ArgumentError('The stop frame is out of the job'); + } - if (frame >= this.size) { - throw new ArgumentError(`The frame with number ${frame} is out of the task`); - } + const result = searchEmptyFrame(this, frameFrom, frameTo); + return result; +}; + +Job.prototype.annotations.save.implementation = async function (onUpdate) { + const result = await saveAnnotations(this, onUpdate); + return result; +}; + +Job.prototype.annotations.merge.implementation = async function (objectStates) { + const result = await mergeAnnotations(this, objectStates); + return result; +}; + +Job.prototype.annotations.split.implementation = async function (objectState, frame) { + const result = await splitAnnotations(this, objectState, frame); + return result; +}; + +Job.prototype.annotations.group.implementation = async function (objectStates, reset) { + const result = await groupAnnotations(this, objectStates, reset); + return result; +}; + +Job.prototype.annotations.hasUnsavedChanges.implementation = function () { + const result = hasUnsavedChanges(this); + return result; +}; + +Job.prototype.annotations.clear.implementation = async function ( + reload, startframe, endframe, delTrackKeyframesOnly, +) { + const result = await clearAnnotations(this, reload, startframe, endframe, delTrackKeyframesOnly); + return result; +}; + +Job.prototype.annotations.select.implementation = function (frame, x, y) { + const result = selectObject(this, frame, x, y); + return result; +}; + +Job.prototype.annotations.statistics.implementation = function () { + const result = annotationsStatistics(this); + return result; +}; + +Job.prototype.annotations.put.implementation = function (objectStates) { + const result = putAnnotations(this, objectStates); + return result; +}; + +Job.prototype.annotations.upload.implementation = async function ( + format: string, + useDefaultLocation: boolean, + sourceStorage: Storage, + file: File | string, +) { + const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file); + return result; +}; + +Job.prototype.annotations.import.implementation = function (data) { + const result = importCollection(this, data); + return result; +}; + +Job.prototype.annotations.export.implementation = function () { + const result = exportCollection(this); + return result; +}; + +Job.prototype.annotations.exportDataset.implementation = async function ( + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + customName?: string, +) { + const result = await exportDataset(this, format, saveImages, useDefaultSettings, targetStorage, customName); + return result; +}; + +Job.prototype.actions.undo.implementation = async function (count) { + const result = await undoActions(this, count); + return result; +}; + +Job.prototype.actions.redo.implementation = async function (count) { + const result = await redoActions(this, count); + return result; +}; + +Job.prototype.actions.freeze.implementation = function (frozen) { + const result = freezeHistory(this, frozen); + return result; +}; + +Job.prototype.actions.clear.implementation = function () { + const result = clearActions(this); + return result; +}; + +Job.prototype.actions.get.implementation = function () { + const result = getActions(this); + return result; +}; + +Job.prototype.logger.log.implementation = async function (logType, payload, wait) { + const result = await loggerStorage.log(logType, { ...payload, task_id: this.taskId, job_id: this.id }, wait); + return result; +}; + +Job.prototype.predictor.status.implementation = async function () { + if (!Number.isInteger(this.projectId)) { + throw new DataError('The job must belong to a project to use the feature'); + } - const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; - - const result = await getFrame( - job.id, - this.dataChunkSize, - this.dataChunkType, - this.mode, - frame, - job.startFrame, - job.stopFrame, - isPlaying, - step, - ); - return result; + const result = await serverProxy.predictor.status(this.projectId); + return { + message: result.message, + progress: result.progress, + projectScore: result.score, + timeRemaining: result.time_remaining, + mediaAmount: result.media_amount, + annotationAmount: result.annotation_amount, }; +}; - Task.prototype.frames.ranges.implementation = async function () { - const rangesData = { - decoded: [], - buffered: [], - }; - for (const job of this.jobs) { - const { decoded, buffered } = await getRanges(job.id); - rangesData.decoded.push(decoded); - rangesData.buffered.push(buffered); - } - return rangesData; - }; +Job.prototype.predictor.predict.implementation = async function (frame) { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); + } - Task.prototype.frames.preview.implementation = async function () { - if (this.id === null) { - return ''; - } + if (frame < this.startFrame || frame > this.stopFrame) { + throw new ArgumentError(`The frame with number ${frame} is out of the job`); + } - const frameData = await getPreview(this.id); - return frameData; - }; + if (!Number.isInteger(this.projectId)) { + throw new DataError('The job must belong to a project to use the feature'); + } - Task.prototype.frames.delete.implementation = async function (frame) { - if (!Number.isInteger(frame)) { - throw new Error(`Frame must be an integer. Got: "${frame}"`); - } + const result = await serverProxy.predictor.predict(this.taskId, frame); + return result; +}; - if (frame < 0 || frame >= this.size) { - throw new Error('The frame is out of the task'); - } +Job.prototype.close.implementation = function closeTask() { + clearFrames(this.id); + closeSession(this); + return this; +}; - const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; - if (job) { - await deleteFrameWrapper.call(this, job.id, frame); - } - }; +Task.prototype.close.implementation = function closeTask() { + for (const job of this.jobs) { + clearFrames(job.id); + closeSession(job); + } - Task.prototype.frames.restore.implementation = async function (frame) { - if (!Number.isInteger(frame)) { - throw new Error(`Frame must be an integer. Got: "${frame}"`); + closeSession(this); + return this; +}; + +Task.prototype.save.implementation = async function (onUpdate) { + // TODO: Add ability to change an owner and an assignee + if (typeof this.id !== 'undefined') { + // If the task has been already created, we update it + const taskData = this._updateTrigger.getUpdated(this, { + bugTracker: 'bug_tracker', + projectId: 'project_id', + assignee: 'assignee_id', + }); + if (taskData.assignee_id) { + taskData.assignee_id = taskData.assignee_id.id; } - - if (frame < 0 || frame >= this.size) { - throw new Error('The frame is out of the task'); + if (taskData.labels) { + taskData.labels = this._internalData.labels; + taskData.labels = taskData.labels.map((el) => el.toJSON()); } - const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; - if (job) { - await restoreFrameWrapper.call(this, job.id, frame); - } - }; + const data = await serverProxy.tasks.save(this.id, taskData); + this._updateTrigger.reset(); + return new Task(data); + } - Task.prototype.frames.save.implementation = async function () { - return Promise.all(this.jobs.map((job) => patchMeta(job.id))); + const taskSpec: any = { + name: this.name, + labels: this.labels.map((el) => el.toJSON()), }; - Task.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) { - if (typeof filters !== 'object') { - throw new ArgumentError('Filters should be an object'); - } - - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); - } - - if (frameFrom < 0 || frameFrom > this.size) { - throw new ArgumentError('The start frame is out of the task'); - } - - if (frameTo < 0 || frameTo > this.size) { - throw new ArgumentError('The stop frame is out of the task'); - } - - const jobs = this.jobs.filter((_job) => ( - (frameFrom >= _job.startFrame && frameFrom <= _job.stopFrame) || - (frameTo >= _job.startFrame && frameTo <= _job.stopFrame) || - (frameFrom < _job.startFrame && frameTo > _job.stopFrame) - )); + if (typeof this.bugTracker !== 'undefined') { + taskSpec.bug_tracker = this.bugTracker; + } + if (typeof this.segmentSize !== 'undefined') { + taskSpec.segment_size = this.segmentSize; + } + if (typeof this.overlap !== 'undefined') { + taskSpec.overlap = this.overlap; + } + if (typeof this.projectId !== 'undefined') { + taskSpec.project_id = this.projectId; + } + if (typeof this.subset !== 'undefined') { + taskSpec.subset = this.subset; + } - if (filters.notDeleted) { - for (const job of jobs) { - const result = await findNotDeletedFrame( - job.id, Math.max(frameFrom, job.startFrame), Math.min(frameTo, job.stopFrame), 1, - ); + if (this.targetStorage) { + taskSpec.target_storage = this.targetStorage.toJSON(); + } - if (result !== null) return result; - } - } + if (this.sourceStorage) { + taskSpec.source_storage = this.sourceStorage.toJSON(); + } - return null; + const taskDataSpec = { + client_files: this.clientFiles, + server_files: this.serverFiles, + remote_files: this.remoteFiles, + image_quality: this.imageQuality, + use_zip_chunks: this.useZipChunks, + use_cache: this.useCache, + sorting_method: this.sortingMethod, }; - // TODO: Check filter for annotations - Task.prototype.annotations.get.implementation = async function (frame, allTracks, filters) { - if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) { - throw new ArgumentError('The filters argument must be an array of strings'); - } + if (typeof this.startFrame !== 'undefined') { + taskDataSpec.start_frame = this.startFrame; + } + if (typeof this.stopFrame !== 'undefined') { + taskDataSpec.stop_frame = this.stopFrame; + } + if (typeof this.frameFilter !== 'undefined') { + taskDataSpec.frame_filter = this.frameFilter; + } + if (typeof this.dataChunkSize !== 'undefined') { + taskDataSpec.chunk_size = this.dataChunkSize; + } + if (typeof this.copyData !== 'undefined') { + taskDataSpec.copy_data = this.copyData; + } + if (typeof this.cloudStorageId !== 'undefined') { + taskDataSpec.cloud_storage_id = this.cloudStorageId; + } - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); - } + const task = await serverProxy.tasks.create(taskSpec, taskDataSpec, onUpdate); + return new Task(task); +}; + +Task.prototype.delete.implementation = async function () { + const result = await serverProxy.tasks.delete(this.id); + return result; +}; + +Task.prototype.backup.implementation = async function ( + targetStorage: Storage, + useDefaultSettings: boolean, + fileName?: string, +) { + const result = await serverProxy.tasks.backup(this.id, targetStorage, useDefaultSettings, fileName); + return result; +}; + +Task.restore.implementation = async function (storage: Storage, file: File | string) { + // eslint-disable-next-line no-unsanitized/method + const result = await serverProxy.tasks.restore(storage, file); + return result; +}; + +Task.prototype.frames.get.implementation = async function (frame, isPlaying, step) { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); + } - if (frame >= this.size) { - throw new ArgumentError(`Frame ${frame} does not exist in the task`); - } + if (frame >= this.size) { + throw new ArgumentError(`The frame with number ${frame} is out of the task`); + } - const result = await getAnnotations(this, frame, allTracks, filters); - const deletedFrames = await getDeletedFrames('task', this.id); - if (frame in deletedFrames) { - return []; - } + const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; + + const result = await getFrame( + job.id, + this.dataChunkSize, + this.dataChunkType, + this.mode, + frame, + job.startFrame, + job.stopFrame, + isPlaying, + step, + ); + return result; +}; + +Task.prototype.frames.ranges.implementation = async function () { + const rangesData = { + decoded: [], + buffered: [], + }; + for (const job of this.jobs) { + const { decoded, buffered } = await getRanges(job.id); + rangesData.decoded.push(decoded); + rangesData.buffered.push(buffered); + } + return rangesData; +}; - return result; - }; +Task.prototype.frames.preview.implementation = async function () { + if (this.id === null) { + return ''; + } - Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { - if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) { - throw new ArgumentError('The filters argument must be an array of strings'); - } + const frameData = await getPreview(this.id); + return frameData; +}; - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); - } +Task.prototype.frames.delete.implementation = async function (frame) { + if (!Number.isInteger(frame)) { + throw new Error(`Frame must be an integer. Got: "${frame}"`); + } - if (frameFrom < 0 || frameFrom >= this.size) { - throw new ArgumentError('The start frame is out of the task'); - } + if (frame < 0 || frame >= this.size) { + throw new Error('The frame is out of the task'); + } - if (frameTo < 0 || frameTo >= this.size) { - throw new ArgumentError('The stop frame is out of the task'); - } + const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; + if (job) { + await deleteFrameWrapper.call(this, job.id, frame); + } +}; - const result = searchAnnotations(this, filters, frameFrom, frameTo); - return result; - }; +Task.prototype.frames.restore.implementation = async function (frame) { + if (!Number.isInteger(frame)) { + throw new Error(`Frame must be an integer. Got: "${frame}"`); + } - Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { - if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { - throw new ArgumentError('The start and end frames both must be an integer'); - } + if (frame < 0 || frame >= this.size) { + throw new Error('The frame is out of the task'); + } - if (frameFrom < 0 || frameFrom >= this.size) { - throw new ArgumentError('The start frame is out of the task'); - } + const job = this.jobs.filter((_job) => _job.startFrame <= frame && _job.stopFrame >= frame)[0]; + if (job) { + await restoreFrameWrapper.call(this, job.id, frame); + } +}; - if (frameTo < 0 || frameTo >= this.size) { - throw new ArgumentError('The stop frame is out of the task'); - } +Task.prototype.frames.save.implementation = async function () { + return Promise.all(this.jobs.map((job) => patchMeta(job.id))); +}; - const result = searchEmptyFrame(this, frameFrom, frameTo); - return result; - }; +Task.prototype.frames.search.implementation = async function (filters, frameFrom, frameTo) { + if (typeof filters !== 'object') { + throw new ArgumentError('Filters should be an object'); + } - Task.prototype.annotations.save.implementation = async function (onUpdate) { - const result = await saveAnnotations(this, onUpdate); - return result; - }; + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - Task.prototype.annotations.merge.implementation = async function (objectStates) { - const result = await mergeAnnotations(this, objectStates); - return result; - }; + if (frameFrom < 0 || frameFrom > this.size) { + throw new ArgumentError('The start frame is out of the task'); + } - Task.prototype.annotations.split.implementation = async function (objectState, frame) { - const result = await splitAnnotations(this, objectState, frame); - return result; - }; + if (frameTo < 0 || frameTo > this.size) { + throw new ArgumentError('The stop frame is out of the task'); + } - Task.prototype.annotations.group.implementation = async function (objectStates, reset) { - const result = await groupAnnotations(this, objectStates, reset); - return result; - }; + const jobs = this.jobs.filter((_job) => ( + (frameFrom >= _job.startFrame && frameFrom <= _job.stopFrame) || + (frameTo >= _job.startFrame && frameTo <= _job.stopFrame) || + (frameFrom < _job.startFrame && frameTo > _job.stopFrame) + )); - Task.prototype.annotations.hasUnsavedChanges.implementation = function () { - const result = hasUnsavedChanges(this); - return result; - }; + if (filters.notDeleted) { + for (const job of jobs) { + const result = await findNotDeletedFrame( + job.id, Math.max(frameFrom, job.startFrame), Math.min(frameTo, job.stopFrame), 1, + ); - Task.prototype.annotations.clear.implementation = async function (reload) { - const result = await clearAnnotations(this, reload); - return result; - }; + if (result !== null) return result; + } + } - Task.prototype.annotations.select.implementation = function (frame, x, y) { - const result = selectObject(this, frame, x, y); - return result; - }; + return null; +}; - Task.prototype.annotations.statistics.implementation = function () { - const result = annotationsStatistics(this); - return result; - }; +// TODO: Check filter for annotations +Task.prototype.annotations.get.implementation = async function (frame, allTracks, filters) { + if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) { + throw new ArgumentError('The filters argument must be an array of strings'); + } - Task.prototype.annotations.put.implementation = function (objectStates) { - const result = putAnnotations(this, objectStates); - return result; - }; + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); + } - Task.prototype.annotations.upload.implementation = async function (file, loader) { - const result = await uploadAnnotations(this, file, loader); - return result; - }; + if (frame >= this.size) { + throw new ArgumentError(`Frame ${frame} does not exist in the task`); + } - Task.prototype.annotations.import.implementation = function (data) { - const result = importAnnotations(this, data); - return result; - }; + const result = await getAnnotations(this, frame, allTracks, filters); + const deletedFrames = await getDeletedFrames('task', this.id); + if (frame in deletedFrames) { + return []; + } - Task.prototype.annotations.export.implementation = function () { - const result = exportAnnotations(this); - return result; - }; + return result; +}; - Task.prototype.annotations.exportDataset.implementation = async function (format, saveImages, customName) { - const result = await exportDataset(this, format, customName, saveImages); - return result; - }; +Task.prototype.annotations.search.implementation = function (filters, frameFrom, frameTo) { + if (!Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string')) { + throw new ArgumentError('The filters argument must be an array of strings'); + } - Task.prototype.actions.undo.implementation = function (count) { - const result = undoActions(this, count); - return result; - }; + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - Task.prototype.actions.redo.implementation = function (count) { - const result = redoActions(this, count); - return result; - }; + if (frameFrom < 0 || frameFrom >= this.size) { + throw new ArgumentError('The start frame is out of the task'); + } - Task.prototype.actions.freeze.implementation = function (frozen) { - const result = freezeHistory(this, frozen); - return result; - }; + if (frameTo < 0 || frameTo >= this.size) { + throw new ArgumentError('The stop frame is out of the task'); + } - Task.prototype.actions.clear.implementation = function () { - const result = clearActions(this); - return result; - }; + const result = searchAnnotations(this, filters, frameFrom, frameTo); + return result; +}; - Task.prototype.actions.get.implementation = function () { - const result = getActions(this); - return result; - }; +Task.prototype.annotations.searchEmpty.implementation = function (frameFrom, frameTo) { + if (!Number.isInteger(frameFrom) || !Number.isInteger(frameTo)) { + throw new ArgumentError('The start and end frames both must be an integer'); + } - Task.prototype.logger.log.implementation = async function (logType, payload, wait) { - const result = await loggerStorage.log(logType, { ...payload, task_id: this.id }, wait); - return result; - }; + if (frameFrom < 0 || frameFrom >= this.size) { + throw new ArgumentError('The start frame is out of the task'); + } - Task.prototype.predictor.status.implementation = async function () { - if (!Number.isInteger(this.projectId)) { - throw new DataError('The task must belong to a project to use the feature'); - } + if (frameTo < 0 || frameTo >= this.size) { + throw new ArgumentError('The stop frame is out of the task'); + } - const result = await serverProxy.predictor.status(this.projectId); - return { - message: result.message, - progress: result.progress, - projectScore: result.score, - timeRemaining: result.time_remaining, - mediaAmount: result.media_amount, - annotationAmount: result.annotation_amount, - }; + const result = searchEmptyFrame(this, frameFrom, frameTo); + return result; +}; + +Task.prototype.annotations.save.implementation = async function (onUpdate) { + const result = await saveAnnotations(this, onUpdate); + return result; +}; + +Task.prototype.annotations.merge.implementation = async function (objectStates) { + const result = await mergeAnnotations(this, objectStates); + return result; +}; + +Task.prototype.annotations.split.implementation = async function (objectState, frame) { + const result = await splitAnnotations(this, objectState, frame); + return result; +}; + +Task.prototype.annotations.group.implementation = async function (objectStates, reset) { + const result = await groupAnnotations(this, objectStates, reset); + return result; +}; + +Task.prototype.annotations.hasUnsavedChanges.implementation = function () { + const result = hasUnsavedChanges(this); + return result; +}; + +Task.prototype.annotations.clear.implementation = async function (reload) { + const result = await clearAnnotations(this, reload); + return result; +}; + +Task.prototype.annotations.select.implementation = function (frame, x, y) { + const result = selectObject(this, frame, x, y); + return result; +}; + +Task.prototype.annotations.statistics.implementation = function () { + const result = annotationsStatistics(this); + return result; +}; + +Task.prototype.annotations.put.implementation = function (objectStates) { + const result = putAnnotations(this, objectStates); + return result; +}; + +Task.prototype.annotations.upload.implementation = async function ( + format: string, + useDefaultLocation: boolean, + sourceStorage: Storage, + file: File | string, +) { + const result = await importDataset(this, format, useDefaultLocation, sourceStorage, file); + return result; +}; + +Task.prototype.annotations.import.implementation = function (data) { + const result = importCollection(this, data); + return result; +}; + +Task.prototype.annotations.export.implementation = function () { + const result = exportCollection(this); + return result; +}; + +Task.prototype.annotations.exportDataset.implementation = async function ( + format: string, + saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + customName?: string, +) { + const result = await exportDataset(this, format, saveImages, useDefaultSettings, targetStorage, customName); + return result; +}; + +Task.prototype.actions.undo.implementation = function (count) { + const result = undoActions(this, count); + return result; +}; + +Task.prototype.actions.redo.implementation = function (count) { + const result = redoActions(this, count); + return result; +}; + +Task.prototype.actions.freeze.implementation = function (frozen) { + const result = freezeHistory(this, frozen); + return result; +}; + +Task.prototype.actions.clear.implementation = function () { + const result = clearActions(this); + return result; +}; + +Task.prototype.actions.get.implementation = function () { + const result = getActions(this); + return result; +}; + +Task.prototype.logger.log.implementation = async function (logType, payload, wait) { + const result = await loggerStorage.log(logType, { ...payload, task_id: this.id }, wait); + return result; +}; + +Task.prototype.predictor.status.implementation = async function () { + if (!Number.isInteger(this.projectId)) { + throw new DataError('The task must belong to a project to use the feature'); + } + + const result = await serverProxy.predictor.status(this.projectId); + return { + message: result.message, + progress: result.progress, + projectScore: result.score, + timeRemaining: result.time_remaining, + mediaAmount: result.media_amount, + annotationAmount: result.annotation_amount, }; +}; - Task.prototype.predictor.predict.implementation = async function (frame) { - if (!Number.isInteger(frame) || frame < 0) { - throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); - } +Task.prototype.predictor.predict.implementation = async function (frame) { + if (!Number.isInteger(frame) || frame < 0) { + throw new ArgumentError(`Frame must be a positive integer. Got: "${frame}"`); + } - if (frame >= this.size) { - throw new ArgumentError(`The frame with number ${frame} is out of the task`); - } + if (frame >= this.size) { + throw new ArgumentError(`The frame with number ${frame} is out of the task`); + } - if (!Number.isInteger(this.projectId)) { - throw new DataError('The task must belong to a project to use the feature'); - } + if (!Number.isInteger(this.projectId)) { + throw new DataError('The task must belong to a project to use the feature'); + } - const result = await serverProxy.predictor.predict(this.id, frame); - return result; - }; -})(); + const result = await serverProxy.predictor.predict(this.id, frame); + return result; +}; diff --git a/cvat-core/src/storage.ts b/cvat-core/src/storage.ts new file mode 100644 index 000000000000..9c0e8d3284d7 --- /dev/null +++ b/cvat-core/src/storage.ts @@ -0,0 +1,66 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { StorageLocation } from './enums'; + +export interface StorageData { + location: StorageLocation; + cloudStorageId?: number; +} + +interface StorageJsonData { + location: StorageLocation; + cloud_storage_id?: number; +} + +/** + * Class representing a storage for import and export resources + * @memberof module:API.cvat.classes + * @hideconstructor + */ +export class Storage { + public location: StorageLocation; + public cloudStorageId: number; + + constructor(initialData: StorageData) { + const data: StorageData = { + location: initialData.location, + cloudStorageId: initialData?.cloudStorageId, + }; + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name location + * @type {module:API.cvat.enums.StorageLocation} + * @memberof module:API.cvat.classes.Storage + * @instance + * @readonly + */ + location: { + get: () => data.location, + }, + /** + * @name cloudStorageId + * @type {number} + * @memberof module:API.cvat.classes.Storage + * @instance + * @readonly + */ + cloudStorageId: { + get: () => data.cloudStorageId, + }, + }), + ); + } + toJSON(): StorageJsonData { + return { + location: this.location, + ...(this.cloudStorageId ? { + cloud_storage_id: this.cloudStorageId, + } : {}), + }; + } +} diff --git a/cvat-core/src/user.ts b/cvat-core/src/user.ts index 74f128b40a11..540f72a72085 100644 --- a/cvat-core/src/user.ts +++ b/cvat-core/src/user.ts @@ -1,180 +1,200 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -(() => { - /** - * Class representing a user - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class User { - constructor(initialData) { - const data = { - id: null, - username: null, - email: null, - first_name: null, - last_name: null, - groups: null, - last_login: null, - date_joined: null, - is_staff: null, - is_superuser: null, - is_active: null, - email_verification_required: null, - }; +interface RawUserData { + id: number; + username: string; + email: string; + first_name: string; + last_name: string; + groups: string[]; + last_login: string; + date_joined: string; + is_staff: boolean; + is_superuser: boolean; + is_active: boolean; + email_verification_required: boolean; +} - for (const property in data) { - if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { - data[property] = initialData[property]; - } - } +export default class User { + public readonly id: number; + public readonly username: string; + public readonly email: string; + public readonly firstName: string; + public readonly lastName: string; + public readonly groups: string[]; + public readonly lastLogin: string; + public readonly dateJoined: string; + public readonly isStaff: boolean; + public readonly isSuperuser: boolean; + public readonly isActive: boolean; + public readonly isVerified: boolean; - Object.defineProperties( - this, - Object.freeze({ - id: { - /** - * @name id - * @type {number} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.id, - }, - username: { - /** - * @name username - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.username, - }, - email: { - /** - * @name email - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.email, - }, - firstName: { - /** - * @name firstName - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.first_name, - }, - lastName: { - /** - * @name lastName - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.last_name, - }, - groups: { - /** - * @name groups - * @type {string[]} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => JSON.parse(JSON.stringify(data.groups)), - }, - lastLogin: { - /** - * @name lastLogin - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.last_login, - }, - dateJoined: { - /** - * @name dateJoined - * @type {string} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.date_joined, - }, - isStaff: { - /** - * @name isStaff - * @type {boolean} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.is_staff, - }, - isSuperuser: { - /** - * @name isSuperuser - * @type {boolean} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.is_superuser, - }, - isActive: { - /** - * @name isActive - * @type {boolean} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => data.is_active, - }, - isVerified: { - /** - * @name isVerified - * @type {boolean} - * @memberof module:API.cvat.classes.User - * @readonly - * @instance - */ - get: () => !data.email_verification_required, - }, - }), - ); - } + constructor(initialData: RawUserData) { + const data = { + id: null, + username: null, + email: null, + first_name: null, + last_name: null, + groups: null, + last_login: null, + date_joined: null, + is_staff: null, + is_superuser: null, + is_active: null, + email_verification_required: null, + }; - serialize() { - return { - id: this.id, - username: this.username, - email: this.email, - first_name: this.firstName, - last_name: this.lastName, - groups: this.groups, - last_login: this.lastLogin, - date_joined: this.dateJoined, - is_staff: this.isStaff, - is_superuser: this.isSuperuser, - is_active: this.isActive, - email_verification_required: this.isVerified, - }; + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } } + + Object.defineProperties( + this, + Object.freeze({ + id: { + /** + * @name id + * @type {number} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.id, + }, + username: { + /** + * @name username + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.username, + }, + email: { + /** + * @name email + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.email, + }, + firstName: { + /** + * @name firstName + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.first_name, + }, + lastName: { + /** + * @name lastName + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.last_name, + }, + groups: { + /** + * @name groups + * @type {string[]} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => JSON.parse(JSON.stringify(data.groups)), + }, + lastLogin: { + /** + * @name lastLogin + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.last_login, + }, + dateJoined: { + /** + * @name dateJoined + * @type {string} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.date_joined, + }, + isStaff: { + /** + * @name isStaff + * @type {boolean} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.is_staff, + }, + isSuperuser: { + /** + * @name isSuperuser + * @type {boolean} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.is_superuser, + }, + isActive: { + /** + * @name isActive + * @type {boolean} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => data.is_active, + }, + isVerified: { + /** + * @name isVerified + * @type {boolean} + * @memberof module:API.cvat.classes.User + * @readonly + * @instance + */ + get: () => !data.email_verification_required, + }, + }), + ); } - module.exports = User; -})(); + serialize(): RawUserData { + return { + id: this.id, + username: this.username, + email: this.email, + first_name: this.firstName, + last_name: this.lastName, + groups: this.groups, + last_login: this.lastLogin, + date_joined: this.dateJoined, + is_staff: this.isStaff, + is_superuser: this.isSuperuser, + is_active: this.isActive, + email_verification_required: this.isVerified, + }; + } +} diff --git a/cvat-core/src/webhook.ts b/cvat-core/src/webhook.ts new file mode 100644 index 000000000000..09424cfff009 --- /dev/null +++ b/cvat-core/src/webhook.ts @@ -0,0 +1,351 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import PluginRegistry from './plugins'; +import User from './user'; +import serverProxy from './server-proxy'; +import { WebhookSourceType, WebhookContentType } from './enums'; +import { isEnum } from './common'; + +interface RawWebhookData { + id?: number; + type: WebhookSourceType; + target_url: string; + organization_id?: number; + project_id?: number; + events: string[]; + content_type: WebhookContentType; + secret?: string; + enable_ssl: boolean; + description?: string; + is_active?: boolean; + owner?: any; + created_date?: string; + updated_date?: string; + last_delivery_date?: string; + last_status?: number; +} + +export default class Webhook { + public readonly id: number; + public readonly type: WebhookSourceType; + public readonly organizationID: number | null; + public readonly projectID: number | null; + public readonly owner: User; + public readonly lastStatus: number; + public readonly lastDeliveryDate?: string; + public readonly createdDate: string; + public readonly updatedDate: string; + + public targetURL: string; + public events: string[]; + public contentType: RawWebhookData['content_type']; + public description?: string; + public secret?: string; + public isActive?: boolean; + public enableSSL: boolean; + + static async availableEvents(type: WebhookSourceType): Promise { + return serverProxy.webhooks.events(type); + } + + constructor(initialData: RawWebhookData) { + const data: RawWebhookData = { + id: undefined, + target_url: '', + type: WebhookSourceType.ORGANIZATION, + events: [], + content_type: WebhookContentType.JSON, + organization_id: null, + project_id: null, + description: undefined, + secret: '', + is_active: undefined, + enable_ssl: undefined, + owner: undefined, + created_date: undefined, + updated_date: undefined, + last_delivery_date: undefined, + last_status: 0, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + if (data.owner) { + data.owner = new User(data.owner); + } + + Object.defineProperties( + this, + Object.freeze({ + id: { + get: () => data.id, + }, + type: { + get: () => data.type, + }, + targetURL: { + get: () => data.target_url, + set: (value: string) => { + if (typeof value !== 'string') { + throw ArgumentError( + `targetURL property must be a string, tried to set ${typeof value}`, + ); + } + data.target_url = value; + }, + }, + events: { + get: () => data.events, + set: (events: string[]) => { + if (!Array.isArray(events)) { + throw ArgumentError( + `Events must be an array, tried to set ${typeof events}`, + ); + } + events.forEach((event: string) => { + if (typeof event !== 'string') { + throw ArgumentError( + `Event must be a string, tried to set ${typeof event}`, + ); + } + }); + data.events = [...events]; + }, + }, + contentType: { + get: () => data.content_type, + set: (value: WebhookContentType) => { + if (!isEnum.call(WebhookContentType, value)) { + throw new ArgumentError( + `Webhook contentType must be member of WebhookContentType, + got wrong value ${typeof value}`, + ); + } + data.content_type = value; + }, + }, + organizationID: { + get: () => data.organization_id, + }, + projectID: { + get: () => data.project_id, + }, + description: { + get: () => data.description, + set: (value: string) => { + if (typeof value !== 'string') { + throw ArgumentError( + `Description property must be a string, tried to set ${typeof value}`, + ); + } + data.description = value; + }, + }, + secret: { + get: () => data.secret, + set: (value: string) => { + if (typeof value !== 'string') { + throw ArgumentError( + `Secret property must be a string, tried to set ${typeof value}`, + ); + } + data.secret = value; + }, + }, + isActive: { + get: () => data.is_active, + set: (value: boolean) => { + if (typeof value !== 'boolean') { + throw ArgumentError( + `isActive property must be a boolean, tried to set ${typeof value}`, + ); + } + data.is_active = value; + }, + }, + enableSSL: { + get: () => data.enable_ssl, + set: (value: boolean) => { + if (typeof value !== 'boolean') { + throw ArgumentError( + `enableSSL property must be a boolean, tried to set ${typeof value}`, + ); + } + data.enable_ssl = value; + }, + }, + owner: { + get: () => data.owner, + }, + createdDate: { + get: () => data.created_date, + }, + updatedDate: { + get: () => data.updated_date, + }, + lastDeliveryDate: { + get: () => data.last_delivery_date, + }, + lastStatus: { + get: () => data.last_status, + }, + }), + ); + } + + public toJSON(): RawWebhookData { + const result: RawWebhookData = { + target_url: this.targetURL, + events: [...this.events], + content_type: this.contentType, + enable_ssl: this.enableSSL, + type: this.type || WebhookSourceType.ORGANIZATION, + }; + + if (Number.isInteger(this.id)) { + result.id = this.id; + } + + if (Number.isInteger(this.organizationID)) { + result.organization_id = this.organizationID; + } + + if (Number.isInteger(this.projectID)) { + result.project_id = this.projectID; + } + + if (this.description) { + result.description = this.description; + } + + if (this.secret) { + result.secret = this.secret; + } + + if (typeof this.isActive === 'boolean') { + result.is_active = this.isActive; + } + + return result; + } + + public async save(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.save); + return result; + } + + public async delete(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.delete); + return result; + } + + public async ping(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.ping); + return result; + } +} + +interface RawWebhookDeliveryData { + id?: number; + event?: string; + webhook_id?: number; + status_code?: string; + redelivery?: boolean; + created_date?: string; + updated_date?: string; +} + +export class WebhookDelivery { + public readonly id?: number; + public readonly event: string; + public readonly webhookId: number; + public readonly statusCode: string; + public readonly createdDate?: string; + public readonly updatedDate?: string; + + constructor(initialData: RawWebhookDeliveryData) { + const data: RawWebhookDeliveryData = { + id: undefined, + event: '', + webhook_id: undefined, + status_code: undefined, + created_date: undefined, + updated_date: undefined, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + Object.defineProperties( + this, + Object.freeze({ + id: { + get: () => data.id, + }, + event: { + get: () => data.event, + }, + webhookId: { + get: () => data.webhook_id, + }, + statusCode: { + get: () => data.status_code, + }, + createdDate: { + get: () => data.created_date, + }, + updatedDate: { + get: () => data.updated_date, + }, + }), + ); + } +} + +Object.defineProperties(Webhook.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + if (Number.isInteger(this.id)) { + const result = await serverProxy.webhooks.update(this.id, this.toJSON()); + return new Webhook(result); + } + + const result = await serverProxy.webhooks.create(this.toJSON()); + return new Webhook(result); + }, + }, +}); + +Object.defineProperties(Webhook.prototype.delete, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + if (Number.isInteger(this.id)) { + await serverProxy.webhooks.delete(this.id); + } + }, + }, +}); + +Object.defineProperties(Webhook.prototype.ping, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation() { + const result = await serverProxy.webhooks.ping(this.id); + return new WebhookDelivery(result); + }, + }, +}); diff --git a/cvat-core/tests/api/annotations.js b/cvat-core/tests/api/annotations.js index de2b1257c3f0..e0d074f33735 100644 --- a/cvat-core/tests/api/annotations.js +++ b/cvat-core/tests/api/annotations.js @@ -1,17 +1,20 @@ // Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2022 CVAT.ai Corp +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api window.cvat = require('../../src/api'); -const serverProxy = require('../../src/server-proxy'); +const serverProxy = require('../../src/server-proxy').default; // Test cases describe('Feature: get annotations', () => { diff --git a/cvat-core/tests/api/cloud-storages.js b/cvat-core/tests/api/cloud-storages.js index 66ccf11abdcb..70a7de37a1e7 100644 --- a/cvat-core/tests/api/cloud-storages.js +++ b/cvat-core/tests/api/cloud-storages.js @@ -1,17 +1,20 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api window.cvat = require('../../src/api'); -const { CloudStorage } = require('../../src/cloud-storage'); +const CloudStorage= require('../../src/cloud-storage').default; const { cloudStoragesDummyData } = require('../mocks/dummy-data.mock'); describe('Feature: get cloud storages', () => { diff --git a/cvat-core/tests/api/frames.js b/cvat-core/tests/api/frames.js index e758da7084fc..a707e4ea1817 100644 --- a/cvat-core/tests/api/frames.js +++ b/cvat-core/tests/api/frames.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api diff --git a/cvat-core/tests/api/jobs.js b/cvat-core/tests/api/jobs.js index 568a96f39acc..3adcdb7f1140 100644 --- a/cvat-core/tests/api/jobs.js +++ b/cvat-core/tests/api/jobs.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api diff --git a/cvat-core/tests/api/object-state.js b/cvat-core/tests/api/object-state.js index bb86279e8555..0d15ca43d116 100644 --- a/cvat-core/tests/api/object-state.js +++ b/cvat-core/tests/api/object-state.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api diff --git a/cvat-core/tests/api/plugins.js b/cvat-core/tests/api/plugins.js index 373adc48c3f7..1f2949abee5e 100644 --- a/cvat-core/tests/api/plugins.js +++ b/cvat-core/tests/api/plugins.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api diff --git a/cvat-core/tests/api/projects.js b/cvat-core/tests/api/projects.js index ea278c241467..9c9426104da8 100644 --- a/cvat-core/tests/api/projects.js +++ b/cvat-core/tests/api/projects.js @@ -1,17 +1,20 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api window.cvat = require('../../src/api'); -const { Project } = require('../../src/project'); +const Project = require('../../src/project').default; describe('Feature: get projects', () => { test('get all projects', async () => { diff --git a/cvat-core/tests/api/server.js b/cvat-core/tests/api/server.js index cab2b203e3ad..d775d89e5408 100644 --- a/cvat-core/tests/api/server.js +++ b/cvat-core/tests/api/server.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api @@ -23,51 +26,51 @@ describe('Feature: get info about cvat', () => { }); }); -describe('Feature: get share storage info', () => { - test('get files in a root of a share storage', async () => { - const result = await window.cvat.server.share(); - expect(Array.isArray(result)).toBeTruthy(); - expect(result).toHaveLength(5); - }); +// describe('Feature: get share storage info', () => { +// test('get files in a root of a share storage', async () => { +// const result = await window.cvat.server.share(); +// expect(Array.isArray(result)).toBeTruthy(); +// expect(result).toHaveLength(5); +// }); - test('get files in a some dir of a share storage', async () => { - const result = await window.cvat.server.share('images'); - expect(Array.isArray(result)).toBeTruthy(); - expect(result).toHaveLength(8); - }); +// test('get files in a some dir of a share storage', async () => { +// const result = await window.cvat.server.share('images'); +// expect(Array.isArray(result)).toBeTruthy(); +// expect(result).toHaveLength(8); +// }); - test('get files in a some unknown dir of a share storage', async () => { - expect(window.cvat.server.share('Unknown Directory')).rejects.toThrow(window.cvat.exceptions.ServerError); - }); -}); +// test('get files in a some unknown dir of a share storage', async () => { +// expect(window.cvat.server.share('Unknown Directory')).rejects.toThrow(window.cvat.exceptions.ServerError); +// }); +// }); -describe('Feature: get annotation formats', () => { - test('get annotation formats from a server', async () => { - const result = await window.cvat.server.formats(); - expect(result).toBeInstanceOf(AnnotationFormats); - }); -}); +// describe('Feature: get annotation formats', () => { +// test('get annotation formats from a server', async () => { +// const result = await window.cvat.server.formats(); +// expect(result).toBeInstanceOf(AnnotationFormats); +// }); +// }); -describe('Feature: get annotation loaders', () => { - test('get annotation formats from a server', async () => { - const result = await window.cvat.server.formats(); - expect(result).toBeInstanceOf(AnnotationFormats); - const { loaders } = result; - expect(Array.isArray(loaders)).toBeTruthy(); - for (const loader of loaders) { - expect(loader).toBeInstanceOf(Loader); - } - }); -}); +// describe('Feature: get annotation loaders', () => { +// test('get annotation formats from a server', async () => { +// const result = await window.cvat.server.formats(); +// expect(result).toBeInstanceOf(AnnotationFormats); +// const { loaders } = result; +// expect(Array.isArray(loaders)).toBeTruthy(); +// for (const loader of loaders) { +// expect(loader).toBeInstanceOf(Loader); +// } +// }); +// }); -describe('Feature: get annotation dumpers', () => { - test('get annotation formats from a server', async () => { - const result = await window.cvat.server.formats(); - expect(result).toBeInstanceOf(AnnotationFormats); - const { dumpers } = result; - expect(Array.isArray(dumpers)).toBeTruthy(); - for (const dumper of dumpers) { - expect(dumper).toBeInstanceOf(Dumper); - } - }); -}); +// describe('Feature: get annotation dumpers', () => { +// test('get annotation formats from a server', async () => { +// const result = await window.cvat.server.formats(); +// expect(result).toBeInstanceOf(AnnotationFormats); +// const { dumpers } = result; +// expect(Array.isArray(dumpers)).toBeTruthy(); +// for (const dumper of dumpers) { +// expect(dumper).toBeInstanceOf(Dumper); +// } +// }); +// }); diff --git a/cvat-core/tests/api/tasks.js b/cvat-core/tests/api/tasks.js index bbb990c81cf8..7321824b7687 100644 --- a/cvat-core/tests/api/tasks.js +++ b/cvat-core/tests/api/tasks.js @@ -1,11 +1,14 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api diff --git a/cvat-core/tests/api/user.js b/cvat-core/tests/api/user.js index 14623b77b3cc..7d4e53ad1229 100644 --- a/cvat-core/tests/api/user.js +++ b/cvat-core/tests/api/user.js @@ -1,17 +1,20 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT // Setup mock for a server jest.mock('../../src/server-proxy', () => { - const mock = require('../mocks/server-proxy.mock'); - return mock; + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; }); // Initialize api window.cvat = require('../../src/api'); -const User = require('../../src/user'); +const User = require('../../src/user').default; // Test cases describe('Feature: get a list of users', () => { diff --git a/cvat-core/tests/api/webhooks.js b/cvat-core/tests/api/webhooks.js new file mode 100644 index 000000000000..0bee1010f04e --- /dev/null +++ b/cvat-core/tests/api/webhooks.js @@ -0,0 +1,124 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +// Setup mock for a server +jest.mock('../../src/server-proxy', () => { + return { + __esModule: true, + default: require('../mocks/server-proxy.mock'), + }; +}); + +// Initialize api +window.cvat = require('../../src/api'); + +const Webhook = require('../../src/webhook').default; +const { webhooksDummyData, webhooksEventsDummyData } = require('../mocks/dummy-data.mock'); +const { WebhookSourceType } = require('../../src/enums'); + +describe('Feature: get webhooks', () => { + test('get all webhooks', async () => { + const result = await window.cvat.webhooks.get({}); + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(webhooksDummyData.count); + for (const item of result) { + expect(item).toBeInstanceOf(Webhook); + } + }); + + test('get webhook events', async () => { + function checkEvents(events) { + expect(Array.isArray(result)).toBeTruthy(); + for (const event of events) { + expect(event).toMatch(/((create)|(update)|(delete)):/); + } + } + let result = await Webhook.availableEvents(WebhookSourceType.PROJECT); + checkEvents(result); + + result = await Webhook.availableEvents(WebhookSourceType.ORGANIZATION); + checkEvents(result); + }); + + test('get webhook by id', async () => { + const result = await window.cvat.webhooks.get({ + id: 1, + }); + const [webhook] = result; + + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(1); + expect(webhook).toBeInstanceOf(Webhook); + expect(webhook.id).toBe(1); + expect(webhook.targetURL).toBe('https://localhost:3001/project/hook'); + expect(webhook.description).toBe('Project webhook'); + expect(webhook.contentType).toBe('application/json'); + expect(webhook.enableSSL).toBeTruthy(); + expect(webhook.events).toEqual(webhooksEventsDummyData[WebhookSourceType.PROJECT].events); + }); +}); + + + +describe('Feature: create a webhook', () => { + test('create new webhook', async () => { + const webhook = new window.cvat.classes.Webhook({ + description: 'New webhook', + target_url: 'https://localhost:3001/hook', + content_type: 'application/json', + secret: 'secret', + enable_ssl: true, + is_active: true, + events: webhooksEventsDummyData[WebhookSourceType.PROJECT].events, + project_id: 1, + type:WebhookSourceType.PROJECT, + }); + + const result = await webhook.save(); + expect(typeof result.id).toBe('number'); + }); +}); + +describe('Feature: update a webhook', () => { + test('update some webhook fields', async () => { + const newValues = new Map([ + ['description', 'New description'], + ['isActive', false], + ['targetURL', 'https://localhost:3001/new/url'], + ]); + + let result = await window.cvat.webhooks.get({ + id: 1, + }); + let [webhook] = result; + for (const [key, value] of newValues) { + webhook[key] = value; + } + webhook.save(); + + result = await window.cvat.webhooks.get({ + id: 1, + }); + [webhook] = result; + newValues.forEach((value, key) => { + expect(webhook[key]).toBe(value); + }); + }); +}); + +describe('Feature: delete a webhook', () => { + test('delete a webhook', async () => { + let result = await window.cvat.webhooks.get({ + id: 2, + }); + const [webhook] = result; + await webhook.delete(); + + result = await window.cvat.webhooks.get({ + id: 2, + }); + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(0); + }); +}); diff --git a/cvat-core/tests/mocks/dummy-data.mock.js b/cvat-core/tests/mocks/dummy-data.mock.js index 2b4edfff1d2d..ef8b15d66213 100644 --- a/cvat-core/tests/mocks/dummy-data.mock.js +++ b/cvat-core/tests/mocks/dummy-data.mock.js @@ -2723,7 +2723,7 @@ const taskAnnotationsDummyData = { ], id: 28, frame: 0, - label_id:59, + label_id: 59, group: 0, source: 'manual', attributes: [] @@ -2989,7 +2989,7 @@ const frameMetaDummyData = { start_frame: 0, stop_frame: 8, frame_filter: '', - deleted_frames: [7,8], + deleted_frames: [7, 8], frames: [ { width: 1920, @@ -3282,6 +3282,165 @@ const cloudStoragesDummyData = { ] }; +const webhooksDummyData = { + count: 3, + next: null, + previous: null, + results: [ + { + id: 1, + url: "http://localhost:7000/api/webhooks/1", + target_url: "https://localhost:3001/project/hook", + description: "Project webhook", + type: "project", + content_type: "application/json", + is_active: true, + enable_ssl: true, + created_date: "2022-09-23T06:29:12.337276Z", + updated_date: "2022-09-23T06:29:12.337316Z", + owner: { + url: "http://localhost:7000/api/users/1", + id: 1, + username: "kirill", + first_name: "", + last_name: "" + }, + project: 1, + organization: 1, + events: [ + "create:comment", + "create:issue", + "create:task", + "delete:comment", + "delete:issue", + "delete:task", + "update:comment", + "update:job", + "update:project", + "update:task" + ], + last_status: "Failed to connect to target url", + last_delivery_date: "2022-09-23T06:28:48.313010Z" + }, + { + id: 2, + url: "http://localhost:7000/api/webhooks/2", + target_url: "https://localhost:3001/example/route", + description: "Second webhook", + type: "organization", + content_type: "application/json", + is_active: true, + enable_ssl: true, + created_date: "2022-09-23T06:28:32.430437Z", + updated_date: "2022-09-23T06:28:32.430474Z", + owner: { + url: "http://localhost:7000/api/users/1", + id: 1, + username: "kirill", + first_name: "", + last_name: "" + }, + project: 1, + organization: 1, + events: [ + "create:project", + "create:task", + "delete:project", + "delete:task", + "update:job", + "update:project", + "update:task" + ], + last_status: "200", + last_delivery_date: "2022-09-23T06:28:48.313010Z" + }, + { + id: 3, + url: "http://localhost:7000/api/webhooks/3", + target_url: "https://localhost:3001/example1", + description: "Example webhook", + type: "organization", + content_type: "application/json", + is_active: true, + enable_ssl: true, + created_date: "2022-09-23T06:27:52.888204Z", + updated_date: "2022-09-23T06:27:52.888245Z", + owner: { + url: "http://localhost:7000/api/users/1", + id: 1, + username: "kirill", + first_name: "", + last_name: "" + }, + project: 1, + organization: 1, + events: [ + "create:comment", + "create:invitation", + "create:issue", + "create:project", + "create:task", + "delete:comment", + "delete:invitation", + "delete:issue", + "delete:membership", + "delete:project", + "delete:task", + "update:comment", + "update:invitation", + "update:job", + "update:membership", + "update:organization", + "update:project", + "update:task" + ], + last_status: "200", + last_delivery_date: "2022-09-23T06:28:48.283962Z" + } + ] +}; + +const webhooksEventsDummyData = { + project: { + webhook_type: "project", + events: [ + "create:comment", + "create:issue", + "create:task", + "delete:comment", + "delete:issue", + "delete:task", + "update:comment", + "update:job", + "update:project", + "update:task" + ] + }, + organization: { + webhook_type: "organization", + events: [ + "create:comment", + "create:invitation", + "create:issue", + "create:project", + "create:task", + "delete:comment", + "delete:invitation", + "delete:issue", + "delete:membership", + "delete:project", + "delete:task", + "update:comment", + "update:invitation", + "update:job", + "update:membership", + "update:organization", + "update:project", + "update:task" + ] + }, +} + module.exports = { tasksDummyData, projectsDummyData, @@ -3293,4 +3452,6 @@ module.exports = { frameMetaDummyData, formatsDummyData, cloudStoragesDummyData, + webhooksDummyData, + webhooksEventsDummyData, }; diff --git a/cvat-core/tests/mocks/server-proxy.mock.js b/cvat-core/tests/mocks/server-proxy.mock.js index 5344491b6ead..9491f9c0bcf6 100644 --- a/cvat-core/tests/mocks/server-proxy.mock.js +++ b/cvat-core/tests/mocks/server-proxy.mock.js @@ -13,6 +13,8 @@ const { jobAnnotationsDummyData, frameMetaDummyData, cloudStoragesDummyData, + webhooksDummyData, + webhooksEventsDummyData, } = require('./dummy-data.mock'); function QueryStringToJSON(query, ignoreList = []) { @@ -412,6 +414,71 @@ class ServerProxy { } } + async function getWebhooks(filter = '') { + const queries = QueryStringToJSON(filter); + const result = webhooksDummyData.results.filter((item) => { + for (const key in queries) { + if (Object.prototype.hasOwnProperty.call(queries, key)) { + if (queries[key] !== item[key]) { + return false; + } + } + } + return true; + }); + return result; + } + + async function createWebhook(webhookData) { + const id = Math.max(...webhooksDummyData.results.map((item) => item.id)) + 1; + webhooksDummyData.results.push({ + id, + description: webhookData.description, + target_url: webhookData.target_url, + content_type: webhookData.content_type, + secret: webhookData.secret, + enable_ssl: webhookData.enable_ssl, + is_active: webhookData.is_active, + events: webhookData.events, + organization_id: webhookData.organization_id ? webhookData.organization_id : null, + project_id: webhookData.project_id ? webhookData.project_id : null, + type: webhookData.type, + owner: { id: 1 }, + created_date: '2022-09-23T06:29:12.337276Z', + updated_date: '2022-09-23T06:29:12.337276Z', + }); + + const result = await getWebhooks(`?id=${id}`); + return result[0]; + } + + async function updateWebhook(webhookID, webhookData) { + const webhook = webhooksDummyData.results.find((item) => item.id === webhookID); + if (webhook) { + for (const prop in webhookData) { + if ( + Object.prototype.hasOwnProperty.call(webhookData, prop) && + Object.prototype.hasOwnProperty.call(webhook, prop) + ) { + webhook[prop] = webhookData[prop]; + } + } + } + return webhook; + } + + async function receiveWebhookEvents(type) { + return webhooksEventsDummyData[type]?.events; + } + + async function deleteWebhook(webhookID) { + const webhooks = webhooksDummyData.results; + const webhookIdx = webhooks.findIndex((item) => item.id === webhookID); + if (webhookIdx !== -1) { + webhooks.splice(webhookIdx); + } + } + Object.defineProperties( this, Object.freeze({ @@ -489,6 +556,17 @@ class ServerProxy { }), writable: false, }, + + webhooks: { + value: Object.freeze({ + get: getWebhooks, + create: createWebhook, + update: updateWebhook, + delete: deleteWebhook, + events: receiveWebhookEvents, + }), + writable: false, + }, }), ); } diff --git a/cvat-sdk/.gitignore b/cvat-sdk/.gitignore index f27f78919cb0..d01a61d14490 100644 --- a/cvat-sdk/.gitignore +++ b/cvat-sdk/.gitignore @@ -70,10 +70,10 @@ schema/ .openapi-generator/ # Generated code -cvat_sdk/api_client/ -cvat_sdk/version.py -requirements/ -docs/ -setup.py -README.md -MANIFEST.in \ No newline at end of file +/cvat_sdk/api_client/ +/cvat_sdk/version.py +/requirements/ +/docs/ +/setup.py +/README.md +/MANIFEST.in \ No newline at end of file diff --git a/cvat-sdk/cvat_sdk/core/client.py b/cvat-sdk/cvat_sdk/core/client.py index cb8c42051c89..ba34572a05ea 100644 --- a/cvat-sdk/cvat_sdk/core/client.py +++ b/cvat-sdk/cvat_sdk/core/client.py @@ -12,11 +12,12 @@ from typing import Any, Dict, Optional, Sequence, Tuple import attrs +import packaging.version as pv import urllib3 import urllib3.exceptions -from cvat_sdk.api_client import ApiClient, Configuration, models -from cvat_sdk.core.exceptions import InvalidHostException +from cvat_sdk.api_client import ApiClient, Configuration, exceptions, models +from cvat_sdk.core.exceptions import IncompatibleVersionException, InvalidHostException from cvat_sdk.core.helpers import expect_status from cvat_sdk.core.proxies.issues import CommentsRepo, IssuesRepo from cvat_sdk.core.proxies.jobs import JobsRepo @@ -24,29 +25,67 @@ from cvat_sdk.core.proxies.projects import ProjectsRepo from cvat_sdk.core.proxies.tasks import TasksRepo from cvat_sdk.core.proxies.users import UsersRepo +from cvat_sdk.version import VERSION @attrs.define class Config: + """ + Allows to tweak behavior of Client instances. + """ + status_check_period: float = 5 - """In seconds""" + """Operation status check period, in seconds""" + + allow_unsupported_server: bool = True + """Allow to use SDK with an unsupported server version. If disabled, raise an exception""" + + verify_ssl: Optional[bool] = None + """Whether to verify host SSL certificate or not""" class Client: """ - Manages session and configuration. + Provides session management, implements authentication operations + and simplifies access to server APIs. """ + SUPPORTED_SERVER_VERSIONS = ( + pv.Version("2.0"), + pv.Version("2.1"), + pv.Version("2.2"), + pv.Version("2.3"), + ) + def __init__( - self, url: str, *, logger: Optional[logging.Logger] = None, config: Optional[Config] = None - ): + self, + url: str, + *, + logger: Optional[logging.Logger] = None, + config: Optional[Config] = None, + check_server_version: bool = True, + ) -> None: url = self._validate_and_prepare_url(url) - self.api_map = CVAT_API_V2(url) - self.api_client = ApiClient(Configuration(host=self.api_map.host)) + self.logger = logger or logging.getLogger(__name__) + """The root logger""" + self.config = config or Config() + """Configuration for this object""" + + self.api_map = CVAT_API_V2(url) + """Handles server API URL interaction logic""" + + self.api_client = ApiClient( + Configuration(host=self.api_map.host, verify_ssl=self.config.verify_ssl) + ) + """Provides low-level access to the CVAT server""" + + if check_server_version: + self.check_server_version() self._repos: Dict[str, Repo] = {} + """A cache for created Repository instances""" ALLOWED_SCHEMAS = ("https", "http") @@ -59,6 +98,8 @@ def _validate_and_prepare_url(cls, url: str) -> str: schema = "" base_url = url + base_url = base_url.rstrip("/") + if schema and schema not in cls.ALLOWED_SCHEMAS: raise InvalidHostException( f"Invalid url schema '{schema}', expected " @@ -76,16 +117,18 @@ def _detect_schema(cls, base_url: str) -> str: for schema in cls.ALLOWED_SCHEMAS: with ApiClient(Configuration(host=f"{schema}://{base_url}")) as api_client: with suppress(urllib3.exceptions.RequestError): - (_, response) = api_client.schema_api.retrieve( + (_, response) = api_client.server_api.retrieve_about( _request_timeout=5, _parse_response=False, _check_status=False ) - if response.status == 401: + if response.status in [200, 401]: + # Server versions prior to 2.3.0 respond with unauthorized + # 2.3.0 allows unauthorized access return schema raise InvalidHostException( "Failed to detect host schema automatically, please check " - "the server url and try to specify schema explicitly" + "the server url and try to specify 'https://' or 'http://' explicitly" ) def __enter__(self): @@ -100,7 +143,7 @@ def close(self) -> None: def login(self, credentials: Tuple[str, str]) -> None: (auth, _) = self.api_client.auth_api.create_login( - models.LoginRequest(username=credentials[0], password=credentials[1]) + models.LoginSerializerExRequest(username=credentials[0], password=credentials[1]) ) assert "sessionid" in self.api_client.cookies @@ -155,6 +198,44 @@ def wait_for_completion( return response + def check_server_version(self, fail_if_unsupported: Optional[bool] = None) -> None: + if fail_if_unsupported is None: + fail_if_unsupported = not self.config.allow_unsupported_server + + try: + server_version = self.get_server_version() + except exceptions.ApiException as e: + msg = ( + "Failed to retrieve server API version: %s. " + "Some SDK functions may not work properly with this server." + ) % (e,) + self.logger.warning(msg) + if fail_if_unsupported: + raise IncompatibleVersionException(msg) + return + + sdk_version = pv.Version(VERSION) + + # We only check base version match. Micro releases and fixes do not affect + # API compatibility in general. + if all( + server_version.base_version != sv.base_version for sv in self.SUPPORTED_SERVER_VERSIONS + ): + msg = ( + "Server version '%s' is not compatible with SDK version '%s'. " + "Some SDK functions may not work properly with this server. " + "You can continue using this SDK, or you can " + "try to update with 'pip install cvat-sdk'." + ) % (server_version, sdk_version) + self.logger.warning(msg) + if fail_if_unsupported: + raise IncompatibleVersionException(msg) + + def get_server_version(self) -> pv.Version: + # TODO: allow to use this endpoint unauthorized + (about, _) = self.api_client.server_api.retrieve_about() + return pv.Version(about.version) + def _get_repo(self, key: str) -> Repo: _repo_map = { "tasks": TasksRepo, @@ -200,7 +281,7 @@ class CVAT_API_V2: """Build parameterized API URLs""" def __init__(self, host: str): - self.host = host + self.host = host.rstrip("/") self.base = self.host + "/api/" self.git = self.host + "/git/repository/" @@ -229,7 +310,7 @@ def make_endpoint_url( def make_client( host: str, *, port: Optional[int] = None, credentials: Optional[Tuple[int, int]] = None ) -> Client: - url = host + url = host.rstrip("/") if port: url = f"{url}:{port}" diff --git a/cvat-sdk/cvat_sdk/core/exceptions.py b/cvat-sdk/cvat_sdk/core/exceptions.py index c458bf02d102..b90a8fc18f54 100644 --- a/cvat-sdk/cvat_sdk/core/exceptions.py +++ b/cvat-sdk/cvat_sdk/core/exceptions.py @@ -9,3 +9,7 @@ class CvatSdkException(Exception): class InvalidHostException(CvatSdkException): """Indicates an invalid hostname error""" + + +class IncompatibleVersionException(CvatSdkException): + """Indicates server and SDK version mismatch""" diff --git a/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py b/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py index df8b86a96707..9f71fdd93658 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py +++ b/cvat-sdk/cvat_sdk/core/proxies/model_proxy.py @@ -55,7 +55,8 @@ def api(self) -> ApiType: class Entity(ModelProxy[ModelType, ApiType]): """ - Represents a single object. Implements related operations and provides access to data members. + Represents a single object. Implements related operations and provides read access + to data members. """ _model: ModelType @@ -121,7 +122,7 @@ class _RepoBase(Repo[ModelType, ApiType]): class ModelCreateMixin(Generic[_EntityT, IModel]): def create(self: Repo, spec: Union[Dict[str, Any], IModel]) -> _EntityT: """ - Creates a new object on the server and returns corresponding local object + Creates a new object on the server and returns the corresponding local object """ (model, _) = self.api.create(spec) @@ -131,7 +132,7 @@ def create(self: Repo, spec: Union[Dict[str, Any], IModel]) -> _EntityT: class ModelRetrieveMixin(Generic[_EntityT]): def retrieve(self: Repo, obj_id: int) -> _EntityT: """ - Retrieves an object from server by ID + Retrieves an object from the server by ID """ (model, _) = self.api.retrieve(id=obj_id) @@ -181,7 +182,7 @@ def _export_update_fields( def fetch(self: Entity) -> Self: """ - Updates current object from the server + Updates the current object from the server """ # TODO: implement revision checking @@ -190,7 +191,9 @@ def fetch(self: Entity) -> Self: def update(self: Entity, values: Union[Dict[str, Any], IModel]) -> Self: """ - Commits local model changes to the server + Commits model changes to the server + + The local object is updated from the server after this operation. """ # TODO: implement revision checking diff --git a/cvat-sdk/cvat_sdk/core/proxies/tasks.py b/cvat-sdk/cvat_sdk/core/proxies/tasks.py index b0510c78be86..b5ae0c5743d8 100644 --- a/cvat-sdk/cvat_sdk/core/proxies/tasks.py +++ b/cvat-sdk/cvat_sdk/core/proxies/tasks.py @@ -77,9 +77,9 @@ def upload_data( if resource_type is ResourceType.LOCAL: pass # handled later elif resource_type is ResourceType.REMOTE: - data = {f"remote_files[{i}]": f for i, f in enumerate(resources)} + data["remote_files"] = resources elif resource_type is ResourceType.SHARE: - data = {f"server_files[{i}]": f for i, f in enumerate(resources)} + data["server_files"] = resources data["image_quality"] = 70 data.update( @@ -104,7 +104,6 @@ def upload_data( self.api.create_data( self.id, data_request=models.DataRequest(**data), - _content_type="multipart/form-data", ) elif resource_type == ResourceType.LOCAL: url = self._client.api_map.make_endpoint_url( diff --git a/cvat-sdk/developer_guide.md b/cvat-sdk/developer_guide.md deleted file mode 100644 index 9cbee779074a..000000000000 --- a/cvat-sdk/developer_guide.md +++ /dev/null @@ -1,48 +0,0 @@ -# Developer guide - -## General info - -Most of the files in this package are generated. The `gen/` directory -contains generator config and templates. - -## How to generate API - -1. Obtain the REST API schema: -```bash -python manage.py spectacular --file schema.yml && mkdir -p cvat-sdk/schema/ && mv schema.yml cvat-sdk/schema/ -``` - -2. Generate package code (call from the package root directory): -```bash -# pip install -r gen/requirements.txt - -./gen/generate.sh -``` - -## How to edit templates - -If you want to edit templates, obtain them from the generator first: - -```bash -docker run --rm -v $PWD:/local \ - openapitools/openapi-generator-cli author template \ - -o /local/generator_templates -g python -``` - -Then, you can copy the modified version of the template you need into -the `gen/templates/openapi-generator/` directory. - -Relevant links: -- [Generator implementation, available variables in templates](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/java/org/openapitools/codegen) -- [Mustache syntax in the generator](https://github.com/OpenAPITools/openapi-generator/wiki/Mustache-Template-Variables) - -## How to test - -API client tests are integrated into REST API tests (`/tests/python/rest_api`) -and SDK tests are placed next to them (`/tests/python/sdk`). -To execute, run: -```bash -pytest tests/python/rest_api tests/python/sdk -``` - -To allow editing of the package, install it with `pip install -e cvat-sdk/`. diff --git a/cvat-sdk/gen/design.md b/cvat-sdk/gen/design.md deleted file mode 100644 index 360c7345c47d..000000000000 --- a/cvat-sdk/gen/design.md +++ /dev/null @@ -1,60 +0,0 @@ -# API design decisions - -Generated API is modified from what `openapi-generator` does by default. -Changes are mostly focused on better user experience - including better -usage patterns and simpler/faster ways to achieve results. - -## Changes - -- Added type annotations for return types and class members - This change required us to implement a custom post-processing script, - which converts generated types into correct type annotations. The types - generated by default are supposed to work with the API implementation - (parameter validation and parsing), but they are not applicable as - type annotations (they have incorrect syntax). Custom post-processing - allowed us to make these types correct type annotations. - Other possible solutions: - - There is a `python-experimental` API generator, which may solve - some issues, but it is unstable and requires python 3.9. Our API - works with 3.7, which is the lowest supported version now. - - Custom templates - partially works, but only in limited cases - (model fields). It's very hard to maintain the template code and - logic for this. Only `if` checks and `for` loops are available in - mustache templates, which is not enough for annotation generation. - -- Separate APIs are embedded into the general `APIClient` class - Now we have: - ```python - with ApiClient(config) as api_client: - result1 = api_client.foo_api.operation1() - result2 = api_client.bar_api.operation2() - ``` - - This showed to be more convenient than default: - ```python - with ApiClient(config) as api_client: - foo_api = FooApi(api_client) - result1 = foo_api.operation1() - result2 = foo_api.operation2() - - bar_api = BarApi(api_client) - result3 = bar_api.operation3() - result4 = bar_api.operation4() - ``` - - This also required custom post-processing. Operation Ids are - [supposed to be unique](https://swagger.io/specification/#operation-object) - in the OpenAPI / Swagger specification. Therefore, we can't generate such - schema on the server, nor we can't expect it to be supported in the - API generator. - -- Operations have IDs like `/_` - This also showed to be more readable and more natural than DRF-spectacular's - default `/_`. - -- Server operations have different types for input and output values - While it can be expected that an endopint with POST/PUT methods available - (like `create` or `partial_update`) has the same type for input and output - (because it looks natural), it also leads to the situation, in which there - are lots of read-/write-only fields, and it becomes hard for understanding. - This clear type separation is supposed to make it simpler for users. diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh index 1dc02a24acdc..903f65e9a249 100755 --- a/cvat-sdk/gen/generate.sh +++ b/cvat-sdk/gen/generate.sh @@ -13,7 +13,7 @@ LIB_NAME="cvat_sdk" LAYER1_LIB_NAME="${LIB_NAME}/api_client" DST_DIR="." TEMPLATE_DIR="gen" -PYTHON_POST_PROCESS_FILE="${TEMPLATE_DIR}/postprocess.py" +POST_PROCESS_SCRIPT="${TEMPLATE_DIR}/postprocess.py" mkdir -p "${DST_DIR}/" rm -f -r "${DST_DIR}/docs" "${DST_DIR}/${LAYER1_LIB_NAME}" "requirements/" @@ -34,5 +34,19 @@ cp -r "${TEMPLATE_DIR}/templates/requirements" "${DST_DIR}/" cp -r "${TEMPLATE_DIR}/templates/MANIFEST.in" "${DST_DIR}/" mv "${DST_DIR}/requirements.txt" "${DST_DIR}/requirements/api_client.txt" -# Do custom postprocessing -"${PYTHON_POST_PROCESS_FILE}" --schema "schema/schema.yml" --input-path "${DST_DIR}/${LIB_NAME}" +# Do custom postprocessing for code files +"${POST_PROCESS_SCRIPT}" --schema "schema/schema.yml" --input-path "${DST_DIR}/${LIB_NAME}" + +# Do custom postprocessing for docs files +"${POST_PROCESS_SCRIPT}" --schema "schema/schema.yml" --input-path "${DST_DIR}/docs" --file-ext '.md' +"${POST_PROCESS_SCRIPT}" --schema "schema/schema.yml" --input-path "${DST_DIR}/README.md" + +API_DOCS_DIR="${DST_DIR}/docs/apis/" +MODEL_DOCS_DIR="${DST_DIR}/docs/models/" +mkdir "${API_DOCS_DIR}" +mkdir "${MODEL_DOCS_DIR}" +mv "${DST_DIR}/docs/"*Api.md "${API_DOCS_DIR}" +mv "${DST_DIR}/docs/"*.md "${MODEL_DOCS_DIR}" +mv "${DST_DIR}/README.md" "${DST_DIR}/docs/" + +cp "${TEMPLATE_DIR}/templates/README.md.template" "${DST_DIR}/README.md" diff --git a/cvat-sdk/gen/postprocess.py b/cvat-sdk/gen/postprocess.py index 8e2476a47a5d..376ad2f353a0 100755 --- a/cvat-sdk/gen/postprocess.py +++ b/cvat-sdk/gen/postprocess.py @@ -1,13 +1,13 @@ #!/usr/bin/env python -# Copyright (C) 2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT +import argparse import os.path as osp import re import sys -from argparse import ArgumentParser from glob import glob from inflection import underscore @@ -29,7 +29,7 @@ def collect_operations(schema): return operations -class Processor: +class Replacer: REPLACEMENT_TOKEN = r"%%%" ARGS_TOKEN = r"!!!" @@ -77,7 +77,7 @@ def make_type_annotation(self, type_repr: str) -> str: def _process_file(self, contents: str): processor_pattern = re.compile( - f"{self.REPLACEMENT_TOKEN}(.*?){self.ARGS_TOKEN}(.*){self.REPLACEMENT_TOKEN}" + f"{self.REPLACEMENT_TOKEN}(.*?){self.ARGS_TOKEN}(.*?){self.REPLACEMENT_TOKEN}" ) matches = list(processor_pattern.finditer(contents)) @@ -102,8 +102,8 @@ def process_file(self, src_path: str): with open(src_path, "w") as f: f.write(contents) - def process_dir(self, dir_path: str): - for filename in glob(dir_path + "/**/*.py", recursive=True): + def process_dir(self, dir_path: str, *, file_ext: str = ".py"): + for filename in glob(dir_path + f"/**/*{file_ext}", recursive=True): try: self.process_file(filename) except Exception as e: @@ -117,26 +117,36 @@ def parse_schema(path): def parse_args(args=None): - parser = ArgumentParser( + parser = argparse.ArgumentParser( add_help=True, - description=""" - Processes generator output files in a custom way, saves results inplace. - - Replacement token: '%(repl_token)s'. - Args separator token: '%(args_token)s'. - Replaces the following patterns in python files: - '%(repl_token)sREPLACER%(args_token)sARG1%(args_token)sARG2...%(repl_token)s' - -> - REPLACER(ARG1, ARG2, ...) value - + formatter_class=argparse.RawTextHelpFormatter, + description="""\ +Processes generator output files in a custom way, saves results inplace. + +Replacement token: '%(repl_token)s'. +Arg separator token: '%(args_token)s'. +Replaces the following patterns in files: + '%(repl_token)sREPLACER%(args_token)sARG1%(args_token)sARG2...%(repl_token)s' + -> + REPLACER(ARG1, ARG2, ...) value + +Available REPLACERs: + %(replacers)s """ % { - "repl_token": Processor.REPLACEMENT_TOKEN, - "args_token": Processor.ARGS_TOKEN, + "repl_token": Replacer.REPLACEMENT_TOKEN, + "args_token": Replacer.ARGS_TOKEN, + "replacers": "\n ".join(Replacer.allowed_actions), }, ) - parser.add_argument("--schema", required=True) - parser.add_argument("--input-path", required=True) + parser.add_argument("--schema", required=True, help="Path to server schema yaml") + parser.add_argument("--input-path", required=True, help="Path to target file or directory") + parser.add_argument( + "--file-ext", + default=".py", + help="If working on a directory, look for " + "files with the specified extension (default: %(default)s)", + ) return parser.parse_args(args) @@ -145,10 +155,10 @@ def main(args=None): args = parse_args(args) schema = parse_schema(args.schema) - processor = Processor(schema=schema) + processor = Replacer(schema=schema) if osp.isdir(args.input_path): - processor.process_dir(args.input_path) + processor.process_dir(args.input_path, file_ext=args.file_ext) elif osp.isfile(args.input_path): processor.process_file(args.input_path) diff --git a/cvat-sdk/gen/templates/README.md.template b/cvat-sdk/gen/templates/README.md.template new file mode 100644 index 000000000000..a2d85c2371d8 --- /dev/null +++ b/cvat-sdk/gen/templates/README.md.template @@ -0,0 +1,26 @@ +# SDK for [Computer Vision Annotation Tool (CVAT)](https://github.com/cvat-ai/cvat) + +This package provides a Python client library for CVAT server. It can be useful for +workflow automation and writing custom CVAT server clients. + +The SDK API includes 2 layers: +- Server API wrappers (`ApiClient`). Located in at `cvat_sdk.api_client` +- High-level tools (`Core`). Located at `cvat_sdk.core` + +Package documentation is available [here](https://opencv.github.io/cvat/docs/api_sdk/sdk). + +## Installation & Usage + +To install a prebuilt package, run the following command in the terminal: + +```bash +pip install cvat-sdk +``` + +To install from the local directory, follow [the developer guide](https://opencv.github.io/cvat/docs/api_sdk/sdk/developer_guide). + +After installation you can import the package: + +```python +import cvat_sdk +``` diff --git a/cvat-sdk/gen/templates/openapi-generator/README.mustache b/cvat-sdk/gen/templates/openapi-generator/README.mustache index 6e37baf11df8..95480268f579 100644 --- a/cvat-sdk/gen/templates/openapi-generator/README.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/README.mustache @@ -16,36 +16,21 @@ For more information, please visit [{{{infoUrl}}}]({{{infoUrl}}}) {{/infoUrl}} ## Installation & Usage -### pip install -If the python package is hosted on a repository, you can install directly using: +To install a prebuilt package, run the following command in the terminal: ```sh -pip install git+https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git +pip install cvat-sdk ``` -(you may need to run `pip` with root permission: `sudo pip install git+https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git`) -Then import the package: -```python -import {{{packageName}}} -``` +To install from the local directory, follow [the developer guide](https://opencv.github.io/cvat/docs/api_sdk/sdk/developer_guide). -### Setuptools +After installation you can import the package: -Install via [Setuptools](http://pypi.python.org/pypi/setuptools). - -```sh -python setup.py install --user -``` -(or `sudo python setup.py install` to install the package for all users) - -Then import the package: ```python -import {{{packageName}}} +import cvat_sdk ``` ## Getting Started -Please follow the [installation procedure](#installation--usage) and then run the following: - {{> README_common }} diff --git a/cvat-sdk/gen/templates/openapi-generator/README_common.mustache b/cvat-sdk/gen/templates/openapi-generator/README_common.mustache index 6515c8b62660..a14bbfe36a32 100644 --- a/cvat-sdk/gen/templates/openapi-generator/README_common.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/README_common.mustache @@ -155,16 +155,16 @@ with make_client(host="{{{basePath}}}") as client: task.remove() ``` -## Documentation for API Endpoints +## Available API Endpoints -All URIs are relative to *{{basePath}}* +All URIs are relative to _{{basePath}}_ Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- -{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}*{{classname}}* | **{{operationId}}** | **{{httpMethod}}** {{path}} | {{summary}} +{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}_{{classname}}_ | [**{{>operation_name}}**](apis/{{classname}}#{{>operation_name}}) | **{{httpMethod}}** {{path}} | {{summary}} {{/operation}}{{/operations}}{{/apis}}{{/apiInfo}} -## Documentation For Models +## Available Models {{#models}}{{#model}} - {{{classname}}} {{/model}}{{/models}} diff --git a/cvat-sdk/gen/templates/openapi-generator/api_doc.mustache b/cvat-sdk/gen/templates/openapi-generator/api_doc.mustache new file mode 100644 index 000000000000..ed8d97d51f0a --- /dev/null +++ b/cvat-sdk/gen/templates/openapi-generator/api_doc.mustache @@ -0,0 +1,59 @@ +# {{classname}} + +{{#description}}{{.}} +{{/description}} + +All URIs are relative to _{{basePath}}_ + +Method | HTTP request | Description +------------- | ------------- | ------------- +{{#operations}}{{#operation}}[**{{>operation_name}}**]({{classname}}#{{>operation_name}}) | **{{httpMethod}}** {{path}} | {{summary}} +{{/operation}}{{/operations}} + +{{#operations}} +{{#operation}} +## **{{>operation_name}}** +> {{#returnType}}{{{.}}} {{/returnType}}{{>operation_name}}({{#requiredParams}}{{^defaultValue}}{{paramName}}{{^-last}}, {{/-last}}{{/defaultValue}}{{/requiredParams}}) + +{{{summary}}}{{#notes}} + +{{{.}}}{{/notes}} + +### Example +{{> api_doc_example }} + +### Parameters +{{^allParams}}This endpoint does not need any parameter.{{/allParams}}{{#allParams}}{{#-last}} +Name | Type | Description | Notes +------------- | ------------- | ------------- | -------------{{/-last}}{{/allParams}} +{{#requiredParams}}{{^defaultValue}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**](../models/{{baseType}}){{/baseType}}| {{description}} | +{{/defaultValue}}{{/requiredParams}}{{#requiredParams}}{{#defaultValue}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**](../models/{{baseType}}){{/baseType}}| {{description}} | defaults to {{{.}}} +{{/defaultValue}}{{/requiredParams}}{{#optionalParams}} **{{paramName}}** | {{^baseType}}**{{dataType}}**{{/baseType}}{{#baseType}}[**{{dataType}}**](../models/{{baseType}}){{/baseType}}| {{description}} | [optional]{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} +{{/optionalParams}} + +### Return type + +{{#returnType}}{{#returnTypeIsPrimitive}}**{{{returnType}}}**{{/returnTypeIsPrimitive}}{{^returnTypeIsPrimitive}}[**{{{returnType}}}**](../models/{{returnBaseType}}){{/returnTypeIsPrimitive}}{{/returnType}}{{^returnType}}None (empty response body){{/returnType}} + +### Authorization + +{{^authMethods}}No authorization required{{/authMethods}}{{#authMethods}}{{{name}}}{{^-last}}, {{/-last}}{{/authMethods}} + +### HTTP request headers + + - **Content-Type**: {{#consumes}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/consumes}}{{^consumes}}Not defined{{/consumes}} + - **Accept**: {{#produces}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}{{^produces}}Not defined{{/produces}} + +{{#responses.0}} + +### HTTP response details + +| Status code | Description | Response headers | +|-------------|-------------|------------------| +{{#responses}} +**{{code}}** | {{message}} | {{#headers}} * {{baseName}} - {{description}}
    {{/headers}}{{^headers.0}} - {{/headers.0}} | +{{/responses}} +{{/responses.0}} + +{{/operation}} +{{/operations}} diff --git a/cvat-sdk/gen/templates/openapi-generator/api_doc_example.mustache b/cvat-sdk/gen/templates/openapi-generator/api_doc_example.mustache new file mode 100644 index 000000000000..d671039b3b96 --- /dev/null +++ b/cvat-sdk/gen/templates/openapi-generator/api_doc_example.mustache @@ -0,0 +1,44 @@ +```python +import time +from {{{packageName}}} import Configuration, ApiClient, exceptions +{{#imports}} +{{.}} +{{/imports}} +from pprint import pprint + +# Set up an API client +# Read Configuration class docs for more info about parameters and authentication methods +configuration = Configuration( + host = "{{{basePath}}}",{{#hasAuthMethods}} +{{#authMethods}} +{{#isBasic}} +{{#isBasicBasic}} + username = 'YOUR_USERNAME', + password = 'YOUR_PASSWORD', +{{/isBasicBasic}} +{{/isBasic}} +{{/authMethods}} +{{/hasAuthMethods}} +) + +with ApiClient(configuration) as api_client: +{{#requiredParams}} +{{^defaultValue}} + {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}} +{{/defaultValue}} +{{/requiredParams}} +{{#optionalParams}} + {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}}{{^required}} (optional){{/required}}{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} +{{/optionalParams}} + + try: + {{#returnType}}(data, response) = {{/returnType}}%%%make_api_name!!!{{classname}}%%%.{{{operationId}}}({{#requiredParams}} + {{^defaultValue}}{{paramName}},{{/defaultValue}}{{/requiredParams}}{{#optionalParams}} + {{paramName}}={{paramName}},{{#-last}} + {{/-last}}{{/optionalParams}}) +{{#returnType}} + pprint(data) +{{/returnType}} + except exceptions.ApiException as e: + print("Exception when calling {{classname}}.{{operationId}}: %s\n" % e) +``` diff --git a/cvat-sdk/gen/templates/openapi-generator/configuration.mustache b/cvat-sdk/gen/templates/openapi-generator/configuration.mustache index 878e09202659..cb64afb6126b 100644 --- a/cvat-sdk/gen/templates/openapi-generator/configuration.mustache +++ b/cvat-sdk/gen/templates/openapi-generator/configuration.mustache @@ -6,6 +6,7 @@ import logging import multiprocessing {{/asyncio}} import sys +import typing import urllib3 from http import client as http_client @@ -18,7 +19,7 @@ JSON_SCHEMA_VALIDATION_KEYWORDS = { 'minLength', 'pattern', 'maxItems', 'minItems' } -class Configuration(object): +class Configuration: """ NOTE: This class is auto generated by OpenAPI Generator @@ -30,9 +31,13 @@ class Configuration(object): Each entry in the dict specifies an API key. The dict key is the name of the security scheme in the OAS specification. The dict value is the API key secret. + Supported key names:{{#authMethods}}{{#isApiKey}} + '{{name}}'{{/isApiKey}}{{/authMethods}} :param api_key_prefix: Dict to store API prefix (e.g. Bearer) The dict key is the name of the security scheme in the OAS specification. The dict value is an API key prefix when generating the auth data. + {{#authMethods}}{{#-first}}Default prefixes for API keys:{{/-first}}{{#isApiKey}}{{#vendorExtensions.x-token-prefix}} + {{name}}: '{{.}}'{{/vendorExtensions.x-token-prefix}}{{/isApiKey}}{{/authMethods}} :param username: Username for HTTP basic authentication :param password: Password for HTTP basic authentication :param discard_unknown_keys: Boolean value indicating whether to discard @@ -72,31 +77,30 @@ class Configuration(object): :param server_operation_variables: Mapping from operation ID to a mapping with string values to replace variables in templated server configuration. The validation of enums is performed for variables with defined enum values before. - :param ssl_ca_cert: str - the path to a file of concatenated CA certificates - in PEM format + :param ssl_ca_cert: the path to a file of concatenated CA certificates in PEM format + :param verify_ssl: whether to verify server SSL certificates or not. {{#hasAuthMethods}} :Example: {{#hasApiKeyMethods}} API Key Authentication Example. - Given the following security scheme in the OpenAPI specification: - components: - securitySchemes: - cookieAuth: # name for the security scheme - type: apiKey - in: cookie - name: JSESSIONID # cookie name - You can programmatically set the cookie: + You can authorize with API token after doing the basic auth the following way: conf = {{{packageName}}}.Configuration( - api_key={'cookieAuth': 'abc123'} - api_key_prefix={'cookieAuth': 'JSESSIONID'} + ... + api_key={ + "sessionAuth": , + "csrfAuth": , + "tokenAuth": , + } ) - The following cookie will be added to the HTTP request: - Cookie: JSESSIONID abc123 + You need to specify all the 3 keys for this kind of auth. + + If your custom server uses another token prefix, use the 'api_key_prefix' parameter. + {{/hasApiKeyMethods}} {{#hasHttpBasicMethods}} @@ -111,6 +115,7 @@ class Configuration(object): Configure API client with HTTP basic authentication: conf = {{{packageName}}}.Configuration( + ..., username='the-user', password='the-password', ) @@ -162,60 +167,73 @@ class Configuration(object): _default = None - def __init__(self, host=None, - api_key=None, api_key_prefix=None, - access_token=None, - username=None, password=None, - discard_unknown_keys=False, - disabled_client_side_validations="", + def __init__(self, + host: typing.Optional[str] = None, + api_key: typing.Optional[typing.Dict[str, str]] = None, + api_key_prefix: typing.Optional[typing.Dict[str, str]] = None, + username: typing.Optional[str] = None, + password: typing.Optional[str]=None, + discard_unknown_keys: bool = False, + disabled_client_side_validations: str = "", {{#hasHttpSignatureMethods}} - signing_info=None, + signing_info=None, {{/hasHttpSignatureMethods}} - server_index=None, server_variables=None, - server_operation_index=None, server_operation_variables=None, - ssl_ca_cert=None, - ): - """Constructor - """ + server_index: typing.Optional[int] = None, + server_variables: typing.Optional[typing.Dict[str, str]] = None, + server_operation_index: typing.Optional[int] = None, + server_operation_variables: typing.Optional[typing.Dict[str, str]] = None, + ssl_ca_cert: typing.Optional[str] = None, + verify_ssl: typing.Optional[bool] = None, + ) -> None: self._base_path = self._fix_host_url("{{{basePath}}}" if host is None else host) - """Default Base url - """ + """Default Base url""" + self.server_index = 0 if server_index is None and host is None else server_index + """Default server index""" + self.server_operation_index = server_operation_index or {} - """Default server index - """ + """Default server operation index""" + self.server_variables = server_variables or {} + """Default server variables""" + self.server_operation_variables = server_operation_variables or {} - """Default server variables - """ + """Default server variables""" + self.temp_folder_path = None - """Temp file folder for downloading files - """ + """Temp file folder for downloading files""" + # Authentication Settings - self.access_token = access_token + self.access_token = None + """Bearer API token""" + self.api_key = {} - """dict to store API key(s) - """ + """dict to store API key(s)""" if api_key: self.api_key = api_key - self.api_key_prefix = {} - """dict to store API prefix (e.g. Bearer) - """ + self.api_key_prefix = { {{#authMethods}}{{#isApiKey}}{{#vendorExtensions.x-token-prefix}} + '{{name}}': '{{.}}'{{/vendorExtensions.x-token-prefix}}{{/isApiKey}}{{/authMethods}} + } + """dict to store API prefix (e.g. Bearer)""" if api_key_prefix: - self.api_key_prefix = api_key_prefix + self.api_key_prefix.update(api_key_prefix) self.refresh_api_key_hook = None - """function hook to refresh API key if expired - """ + """function hook to refresh API key if expired""" + self.username = username - """Username for HTTP basic authentication - """ + """Username for HTTP basic authentication""" + self.password = password - """Password for HTTP basic authentication - """ + """Password for HTTP basic authentication""" + self.discard_unknown_keys = discard_unknown_keys + """A flag to control unknown key deserialization behaviour""" + self.disabled_client_side_validations = disabled_client_side_validations + """A flag to enable or disable specific model field validation in the client""" + {{#hasHttpSignatureMethods}} if signing_info is not None: signing_info.host = host @@ -224,43 +242,44 @@ class Configuration(object): """ {{/hasHttpSignatureMethods}} self.logger = {} - """Logging Settings - """ + """Logging Settings""" + self.logger["package_logger"] = logging.getLogger("{{packageName}}") self.logger["urllib3_logger"] = logging.getLogger("urllib3") + self.logger_format = '%(asctime)s %(levelname)s %(message)s' - """Log format - """ + """Log format""" + self.logger_stream_handler = None - """Log stream handler - """ + """Log stream handler""" + self.logger_file_handler = None - """Log file handler - """ + """Log file handler""" + self.logger_file = None - """Debug file location - """ + """Debug file location""" + self.debug = False - """Debug switch - """ + """Debug switch""" - self.verify_ssl = True - """SSL/TLS verification - Set this to false to skip verifying SSL certificate when calling API - from https server. + self.verify_ssl = verify_ssl if verify_ssl is not None else True """ - self.ssl_ca_cert = ssl_ca_cert - """Set this to customize the certificate file to verify the peer. + SSL/TLS verification + Set this to false to skip verifying SSL certificate when calling API + from https server. """ + + self.ssl_ca_cert = ssl_ca_cert + """Set this to customize the certificate file to verify the peer.""" + self.cert_file = None - """client certificate file - """ + """client certificate file""" + self.key_file = None - """client key file - """ + """client key file""" + self.assert_hostname = None - """Set this to True/False to enable/disable SSL hostname verification. - """ + """Set this to True/False to enable/disable SSL hostname verification.""" {{#asyncio}} self.connection_pool_maxsize = 100 @@ -279,20 +298,20 @@ class Configuration(object): {{/asyncio}} self.proxy = None - """Proxy URL - """ + """Proxy URL""" + self.no_proxy = None - """bypass proxy for host in the no_proxy list. - """ + """bypass proxy for host in the no_proxy list.""" + self.proxy_headers = None - """Proxy headers - """ + """Proxy headers""" + self.safe_chars_for_path_param = '' - """Safe chars for path_param - """ + """Safe chars for path_param""" + self.retries = None - """Adding retries to override urllib3 default value 3 - """ + """Adding retries to override urllib3 default value 3""" + # Enable client side validation self.client_side_validation = True @@ -439,7 +458,9 @@ class Configuration(object): self.__logger_format = value self.logger_formatter = logging.Formatter(self.__logger_format) - def get_api_key_with_prefix(self, identifier, alias=None): + def get_api_key_with_prefix(self, identifier: str, *, + alias: typing.Optional[str] = None + ) -> typing.Optional[str]: """Gets API key (with prefix if set). :param identifier: The identifier of apiKey. diff --git a/cvat-sdk/gen/templates/openapi-generator/model_doc.mustache b/cvat-sdk/gen/templates/openapi-generator/model_doc.mustache new file mode 100644 index 000000000000..94d13c14e327 --- /dev/null +++ b/cvat-sdk/gen/templates/openapi-generator/model_doc.mustache @@ -0,0 +1,32 @@ +{{#models}}{{#model}}# {{classname}} + +{{#description}}{{&description}} +{{/description}} + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +{{#isEnum}} +**value** | {{^arrayModelType}}**{{dataType}}**{{/arrayModelType}} | {{description}} | {{#defaultValue}}{{#hasRequired}} if omitted the server will use the default value of {{/hasRequired}}{{^hasRequired}}defaults to {{/hasRequired}}{{{.}}}{{/defaultValue}}{{#allowableValues}}{{#defaultValue}}, {{/defaultValue}} must be one of [{{#enumVars}}{{{value}}}, {{/enumVars}}]{{/allowableValues}} +{{/isEnum}} +{{#isAlias}} +**value** | {{^arrayModelType}}**{{dataType}}**{{/arrayModelType}} | {{description}} | {{#defaultValue}}{{#hasRequired}} if omitted the server will use the default value of {{/hasRequired}}{{^hasRequired}}defaults to {{/hasRequired}}{{{.}}}{{/defaultValue}} +{{/isAlias}} +{{#isArray}} +**value** | {{^arrayModelType}}**{{dataType}}**{{/arrayModelType}}{{#arrayModelType}}[**{{dataType}}**]({{arrayModelType}}){{/arrayModelType}} | {{description}} | {{#defaultValue}}{{#hasRequired}} if omitted the server will use the default value of {{/hasRequired}}{{^hasRequired}}defaults to {{/hasRequired}}{{{.}}}{{/defaultValue}} +{{/isArray}} +{{#requiredVars}} +{{^defaultValue}} +**{{name}}** | {{^complexType}}**{{dataType}}**{{/complexType}}{{#complexType}}[**{{dataType}}**]({{complexType}}){{/complexType}} | {{description}} | {{#isReadOnly}}[readonly] {{/isReadOnly}} +{{/defaultValue}} +{{/requiredVars}} +{{#requiredVars}} +{{#defaultValue}} +**{{name}}** | {{^complexType}}**{{dataType}}**{{/complexType}}{{#complexType}}[**{{dataType}}**]({{complexType}}){{/complexType}} | {{description}} | {{^required}}[optional] {{/required}}{{#isReadOnly}}[readonly] {{/isReadOnly}}{{#defaultValue}}defaults to {{{.}}}{{/defaultValue}} +{{/defaultValue}} +{{/requiredVars}} +{{#optionalVars}} +**{{name}}** | {{^complexType}}**{{dataType}}**{{/complexType}}{{#complexType}}[**{{dataType}}**]({{complexType}}){{/complexType}} | {{description}} | [optional] {{#isReadOnly}}[readonly] {{/isReadOnly}}{{#defaultValue}} if omitted the server will use the default value of {{{.}}}{{/defaultValue}} +{{/optionalVars}} + +{{/model}}{{/models}} diff --git a/cvat-sdk/gen/templates/requirements/base.txt b/cvat-sdk/gen/templates/requirements/base.txt index f22bae3b6fe0..ffc88d7e7eff 100644 --- a/cvat-sdk/gen/templates/requirements/base.txt +++ b/cvat-sdk/gen/templates/requirements/base.txt @@ -1,7 +1,8 @@ -r api_client.txt attrs >= 21.4.0 +packaging >= 21.3 Pillow >= 9.0.1 tqdm >= 4.64.0 tuspy == 0.2.5 # have it pinned, because SDK has lots of patched TUS code -typing_extensions >= 4.2.0 +typing_extensions >= 4.2.0 \ No newline at end of file diff --git a/cvat-ui/index.d.ts b/cvat-ui/index.d.ts index 93c43b1d509e..5a18ea07ae22 100644 --- a/cvat-ui/index.d.ts +++ b/cvat-ui/index.d.ts @@ -1,6 +1,8 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT +import 'redux-thunk/extend-redux'; declare module '*.svg'; declare module 'cvat-core/src/api'; diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 74411692f236..ec2c9f7a70d0 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.41.5", + "version": "1.42.2", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { @@ -35,6 +35,9 @@ "@types/resize-observer-browser": "^0.1.6", "antd": "~4.18.9", "copy-to-clipboard": "^3.3.1", + "cvat-canvas": "link:./../cvat-canvas", + "cvat-canvas3d": "link:./../cvat-canvas3d", + "cvat-core": "link:./../cvat-core", "dotenv-webpack": "^7.1.0", "error-stack-parser": "^2.0.6", "lodash": "^4.17.21", diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index b404333f53c1..584850079e26 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -50,7 +50,7 @@ function getStore(): Store { return store; } -function receiveAnnotationsParameters(): AnnotationsParameters { +export function receiveAnnotationsParameters(): AnnotationsParameters { if (store === null) { store = getCVATStore(); } @@ -89,7 +89,7 @@ export function computeZRange(states: any[]): number[] { return [minZ, maxZ]; } -async function jobInfoGenerator(job: any): Promise> { +export async function jobInfoGenerator(job: any): Promise> { const { total } = await job.annotations.statistics(); return { 'frame count': job.stopFrame - job.startFrame + 1, @@ -350,74 +350,6 @@ export function removeAnnotationsAsync( }; } -export function uploadJobAnnotationsAsync(job: any, loader: any, file: File): ThunkAction { - return async (dispatch: ActionCreator): Promise => { - try { - const state: CombinedState = getStore().getState(); - const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); - - if (state.tasks.activities.loads[job.taskId]) { - throw Error('Annotations is being uploaded for the task'); - } - if (state.annotation.activities.loads[job.id]) { - throw Error('Only one uploading of annotations for a job allowed at the same time'); - } - - dispatch({ - type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS, - payload: { - job, - loader, - }, - }); - - const frame = state.annotation.player.frame.number; - await job.annotations.upload(file, loader); - - await job.logger.log(LogType.uploadAnnotations, { - ...(await jobInfoGenerator(job)), - }); - - await job.annotations.clear(true); - await job.actions.clear(); - const history = await job.actions.get(); - - // One more update to escape some problems - // in canvas when shape with the same - // clientID has different type (polygon, rectangle) for example - dispatch({ - type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, - payload: { - job, - states: [], - history, - }, - }); - - const states = await job.annotations.get(frame, showAllInterpolationTracks, filters); - - setTimeout(() => { - dispatch({ - type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, - payload: { - history, - job, - states, - }, - }); - }); - } catch (error) { - dispatch({ - type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_FAILED, - payload: { - job, - error, - }, - }); - } - }; -} - export function collectStatisticsAsync(sessionInstance: any): ThunkAction { return async (dispatch: ActionCreator): Promise => { try { diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index bc908253d1e4..143a8be16f84 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -100,11 +101,11 @@ export const registerAsync = ( } }; -export const loginAsync = (username: string, password: string): ThunkAction => async (dispatch) => { +export const loginAsync = (credential: string, password: string): ThunkAction => async (dispatch) => { dispatch(authActions.login()); try { - await cvat.server.login(username, password); + await cvat.server.login(credential, password); const users = await cvat.users.get({ self: true }); dispatch(authActions.loginSuccess(users[0])); } catch (error) { diff --git a/cvat-ui/src/actions/export-actions.ts b/cvat-ui/src/actions/export-actions.ts index d12aa37e7639..fe38dc73913f 100644 --- a/cvat-ui/src/actions/export-actions.ts +++ b/cvat-ui/src/actions/export-actions.ts @@ -1,51 +1,130 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; +import { getCore, Storage } from 'cvat-core-wrapper'; + +const core = getCore(); + export enum ExportActionTypes { - OPEN_EXPORT_MODAL = 'OPEN_EXPORT_MODAL', - CLOSE_EXPORT_MODAL = 'CLOSE_EXPORT_MODAL', + OPEN_EXPORT_DATASET_MODAL = 'OPEN_EXPORT_DATASET_MODAL', + CLOSE_EXPORT_DATASET_MODAL = 'CLOSE_EXPORT_DATASET_MODAL', EXPORT_DATASET = 'EXPORT_DATASET', EXPORT_DATASET_SUCCESS = 'EXPORT_DATASET_SUCCESS', EXPORT_DATASET_FAILED = 'EXPORT_DATASET_FAILED', + OPEN_EXPORT_BACKUP_MODAL = 'OPEN_EXPORT_BACKUP_MODAL', + CLOSE_EXPORT_BACKUP_MODAL = 'CLOSE_EXPORT_BACKUP_MODAL', + EXPORT_BACKUP = 'EXPORT_BACKUP', + EXPORT_BACKUP_SUCCESS = 'EXPORT_BACKUP_SUCCESS', + EXPORT_BACKUP_FAILED = 'EXPORT_BACKUP_FAILED', } export const exportActions = { - openExportModal: (instance: any) => createAction(ExportActionTypes.OPEN_EXPORT_MODAL, { instance }), - closeExportModal: () => createAction(ExportActionTypes.CLOSE_EXPORT_MODAL), + openExportDatasetModal: (instance: any) => ( + createAction(ExportActionTypes.OPEN_EXPORT_DATASET_MODAL, { instance }) + ), + closeExportDatasetModal: (instance: any) => ( + createAction(ExportActionTypes.CLOSE_EXPORT_DATASET_MODAL, { instance }) + ), exportDataset: (instance: any, format: string) => ( createAction(ExportActionTypes.EXPORT_DATASET, { instance, format }) ), - exportDatasetSuccess: (instance: any, format: string) => ( - createAction(ExportActionTypes.EXPORT_DATASET_SUCCESS, { instance, format }) + exportDatasetSuccess: ( + instance: any, + instanceType: 'project' | 'task' | 'job', + format: string, + isLocal: boolean, + resource: 'Dataset' | 'Annotations', + ) => ( + createAction(ExportActionTypes.EXPORT_DATASET_SUCCESS, { + instance, + instanceType, + format, + isLocal, + resource, + }) ), - exportDatasetFailed: (instance: any, format: string, error: any) => ( + exportDatasetFailed: (instance: any, instanceType: 'project' | 'task' | 'job', format: string, error: any) => ( createAction(ExportActionTypes.EXPORT_DATASET_FAILED, { instance, + instanceType, format, error, }) ), + openExportBackupModal: (instance: any) => ( + createAction(ExportActionTypes.OPEN_EXPORT_BACKUP_MODAL, { instance }) + ), + closeExportBackupModal: (instance: any) => ( + createAction(ExportActionTypes.CLOSE_EXPORT_BACKUP_MODAL, { instance }) + ), + exportBackup: (instance: any) => ( + createAction(ExportActionTypes.EXPORT_BACKUP, { instance }) + ), + exportBackupSuccess: (instance: any, instanceType: 'task' | 'project', isLocal: boolean) => ( + createAction(ExportActionTypes.EXPORT_BACKUP_SUCCESS, { instance, instanceType, isLocal }) + ), + exportBackupFailed: (instance: any, instanceType: 'task' | 'project', error: any) => ( + createAction(ExportActionTypes.EXPORT_BACKUP_FAILED, { instance, instanceType, error }) + ), }; export const exportDatasetAsync = ( instance: any, format: string, - name: string, saveImages: boolean, + useDefaultSettings: boolean, + targetStorage: Storage, + name?: string, ): ThunkAction => async (dispatch) => { dispatch(exportActions.exportDataset(instance, format)); + let instanceType: 'project' | 'task' | 'job'; + if (instance instanceof core.classes.Project) { + instanceType = 'project'; + } else if (instance instanceof core.classes.Task) { + instanceType = 'task'; + } else { + instanceType = 'job'; + } + + try { + const result = await instance.annotations + .exportDataset(format, saveImages, useDefaultSettings, targetStorage, name); + if (result) { + const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; + downloadAnchor.href = result; + downloadAnchor.click(); + } + const resource = saveImages ? 'Dataset' : 'Annotations'; + dispatch(exportActions.exportDatasetSuccess(instance, instanceType, format, !!result, resource)); + } catch (error) { + dispatch(exportActions.exportDatasetFailed(instance, instanceType, format, error)); + } +}; + +export const exportBackupAsync = ( + instance: any, + targetStorage: Storage, + useDefaultSetting: boolean, + fileName?: string, +): ThunkAction => async (dispatch) => { + dispatch(exportActions.exportBackup(instance)); + const instanceType = (instance instanceof core.classes.Project) ? 'project' : 'task'; + try { - const url = await instance.annotations.exportDataset(format, saveImages, name); - const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; - downloadAnchor.href = url; - downloadAnchor.click(); - dispatch(exportActions.exportDatasetSuccess(instance, format)); + const result = await instance.backup(targetStorage, useDefaultSetting, fileName); + if (result) { + const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; + downloadAnchor.href = result; + downloadAnchor.click(); + } + dispatch(exportActions.exportBackupSuccess(instance, instanceType, !!result)); } catch (error) { - dispatch(exportActions.exportDatasetFailed(instance, format, error)); + dispatch(exportActions.exportBackupFailed(instance, instanceType, error as Error)); } }; diff --git a/cvat-ui/src/actions/import-actions.ts b/cvat-ui/src/actions/import-actions.ts index 94fcd56207e2..6372850b31a3 100644 --- a/cvat-ui/src/actions/import-actions.ts +++ b/cvat-ui/src/actions/import-actions.ts @@ -1,58 +1,167 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { createAction, ActionUnion, ThunkAction } from 'utils/redux'; import { CombinedState } from 'reducers'; +import { getCore, Storage } from 'cvat-core-wrapper'; +import { LogType } from 'cvat-logger'; import { getProjectsAsync } from './projects-actions'; +import { jobInfoGenerator, receiveAnnotationsParameters, AnnotationActionTypes } from './annotation-actions'; + +const core = getCore(); export enum ImportActionTypes { - OPEN_IMPORT_MODAL = 'OPEN_IMPORT_MODAL', - CLOSE_IMPORT_MODAL = 'CLOSE_IMPORT_MODAL', + OPEN_IMPORT_DATASET_MODAL = 'OPEN_IMPORT_DATASET_MODAL', + CLOSE_IMPORT_DATASET_MODAL = 'CLOSE_IMPORT_DATASET_MODAL', IMPORT_DATASET = 'IMPORT_DATASET', IMPORT_DATASET_SUCCESS = 'IMPORT_DATASET_SUCCESS', IMPORT_DATASET_FAILED = 'IMPORT_DATASET_FAILED', IMPORT_DATASET_UPDATE_STATUS = 'IMPORT_DATASET_UPDATE_STATUS', + OPEN_IMPORT_BACKUP_MODAL = 'OPEN_IMPORT_BACKUP_MODAL', + CLOSE_IMPORT_BACKUP_MODAL = 'CLOSE_IMPORT_BACKUP_MODAL', + IMPORT_BACKUP = 'IMPORT_BACKUP', + IMPORT_BACKUP_SUCCESS = 'IMPORT_BACKUP_SUCCESS', + IMPORT_BACKUP_FAILED = 'IMPORT_BACKUP_FAILED', } export const importActions = { - openImportModal: (instance: any) => createAction(ImportActionTypes.OPEN_IMPORT_MODAL, { instance }), - closeImportModal: () => createAction(ImportActionTypes.CLOSE_IMPORT_MODAL), - importDataset: (projectId: number) => ( - createAction(ImportActionTypes.IMPORT_DATASET, { id: projectId }) + openImportDatasetModal: (instance: any) => ( + createAction(ImportActionTypes.OPEN_IMPORT_DATASET_MODAL, { instance }) + ), + closeImportDatasetModal: (instance: any) => ( + createAction(ImportActionTypes.CLOSE_IMPORT_DATASET_MODAL, { instance }) + ), + importDataset: (instance: any, format: string) => ( + createAction(ImportActionTypes.IMPORT_DATASET, { instance, format }) ), - importDatasetSuccess: () => ( - createAction(ImportActionTypes.IMPORT_DATASET_SUCCESS) + importDatasetSuccess: (instance: any, resource: 'dataset' | 'annotation') => ( + createAction(ImportActionTypes.IMPORT_DATASET_SUCCESS, { instance, resource }) ), - importDatasetFailed: (instance: any, error: any) => ( + importDatasetFailed: (instance: any, resource: 'dataset' | 'annotation', error: any) => ( createAction(ImportActionTypes.IMPORT_DATASET_FAILED, { instance, + resource, error, }) ), - importDatasetUpdateStatus: (progress: number, status: string) => ( - createAction(ImportActionTypes.IMPORT_DATASET_UPDATE_STATUS, { progress, status }) + importDatasetUpdateStatus: (instance: any, progress: number, status: string) => ( + createAction(ImportActionTypes.IMPORT_DATASET_UPDATE_STATUS, { instance, progress, status }) + ), + openImportBackupModal: (instanceType: 'project' | 'task') => ( + createAction(ImportActionTypes.OPEN_IMPORT_BACKUP_MODAL, { instanceType }) + ), + closeImportBackupModal: (instanceType: 'project' | 'task') => ( + createAction(ImportActionTypes.CLOSE_IMPORT_BACKUP_MODAL, { instanceType }) + ), + importBackup: () => createAction(ImportActionTypes.IMPORT_BACKUP), + importBackupSuccess: (instanceId: number, instanceType: 'project' | 'task') => ( + createAction(ImportActionTypes.IMPORT_BACKUP_SUCCESS, { instanceId, instanceType }) + ), + importBackupFailed: (instanceType: 'project' | 'task', error: any) => ( + createAction(ImportActionTypes.IMPORT_BACKUP_FAILED, { instanceType, error }) ), }; -export const importDatasetAsync = (instance: any, format: string, file: File): ThunkAction => ( +export const importDatasetAsync = ( + instance: any, + format: string, + useDefaultSettings: boolean, + sourceStorage: Storage, + file: File | string, +): ThunkAction => ( async (dispatch, getState) => { + const resource = instance instanceof core.classes.Project ? 'dataset' : 'annotation'; + try { const state: CombinedState = getState(); - if (state.import.importingId !== null) { - throw Error('Only one importing of dataset allowed at the same time'); + + if (instance instanceof core.classes.Project) { + if (state.import.projects.dataset.current?.[instance.id]) { + throw Error('Only one importing of annotation/dataset allowed at the same time'); + } + dispatch(importActions.importDataset(instance, format)); + await instance.annotations + .importDataset(format, useDefaultSettings, sourceStorage, file, + (message: string, progress: number) => ( + dispatch(importActions.importDatasetUpdateStatus( + instance, Math.floor(progress * 100), message, + )) + )); + } else if (instance instanceof core.classes.Task) { + if (state.import.tasks.dataset.current?.[instance.id]) { + throw Error('Only one importing of annotation/dataset allowed at the same time'); + } + dispatch(importActions.importDataset(instance, format)); + await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file); + } else { // job + if (state.import.tasks.dataset.current?.[instance.taskId]) { + throw Error('Annotations is being uploaded for the task'); + } + if (state.import.jobs.dataset.current?.[instance.id]) { + throw Error('Only one uploading of annotations for a job allowed at the same time'); + } + const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); + + dispatch(importActions.importDataset(instance, format)); + + const frame = state.annotation.player.frame.number; + await instance.annotations.upload(format, useDefaultSettings, sourceStorage, file); + + await instance.logger.log(LogType.uploadAnnotations, { + ...(await jobInfoGenerator(instance)), + }); + + await instance.annotations.clear(true); + await instance.actions.clear(); + const history = await instance.actions.get(); + + // One more update to escape some problems + // in canvas when shape with the same + // clientID has different type (polygon, rectangle) for example + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, + payload: { + states: [], + history, + }, + }); + + const states = await instance.annotations.get(frame, showAllInterpolationTracks, filters); + + setTimeout(() => { + dispatch({ + type: AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS, + payload: { + history, + states, + }, + }); + }); } - dispatch(importActions.importDataset(instance.id)); - await instance.annotations.importDataset(format, file, (message: string, progress: number) => ( - dispatch(importActions.importDatasetUpdateStatus(Math.floor(progress * 100), message)) - )); } catch (error) { - dispatch(importActions.importDatasetFailed(instance, error)); + dispatch(importActions.importDatasetFailed(instance, resource, error)); return; } - dispatch(importActions.importDatasetSuccess()); - dispatch(getProjectsAsync({ id: instance.id }, getState().projects.tasksGettingQuery)); + dispatch(importActions.importDatasetSuccess(instance, resource)); + if (instance instanceof core.classes.Project) { + dispatch(getProjectsAsync({ id: instance.id }, getState().projects.tasksGettingQuery)); + } + } +); + +export const importBackupAsync = (instanceType: 'project' | 'task', storage: Storage, file: File | string): ThunkAction => ( + async (dispatch) => { + dispatch(importActions.importBackup()); + try { + const inctanceClass = (instanceType === 'task') ? core.classes.Task : core.classes.Project; + const instance = await inctanceClass.restore(storage, file); + dispatch(importActions.importBackupSuccess(instance.id, instanceType)); + } catch (error) { + dispatch(importActions.importBackupFailed(instanceType, error)); + } } ); diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index f06da0f1b5d3..51a90e71933c 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -1,4 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -28,12 +29,6 @@ export enum ProjectsActionTypes { DELETE_PROJECT = 'DELETE_PROJECT', DELETE_PROJECT_SUCCESS = 'DELETE_PROJECT_SUCCESS', DELETE_PROJECT_FAILED = 'DELETE_PROJECT_FAILED', - BACKUP_PROJECT = 'BACKUP_PROJECT', - BACKUP_PROJECT_SUCCESS = 'BACKUP_PROJECT_SUCCESS', - BACKUP_PROJECT_FAILED = 'BACKUP_PROJECT_FAILED', - RESTORE_PROJECT = 'IMPORT_PROJECT', - RESTORE_PROJECT_SUCCESS = 'IMPORT_PROJECT_SUCCESS', - RESTORE_PROJECT_FAILED = 'IMPORT_PROJECT_FAILED', } // prettier-ignore @@ -63,20 +58,6 @@ const projectActions = { deleteProjectFailed: (projectId: number, error: any) => ( createAction(ProjectsActionTypes.DELETE_PROJECT_FAILED, { projectId, error }) ), - backupProject: (projectId: number) => createAction(ProjectsActionTypes.BACKUP_PROJECT, { projectId }), - backupProjectSuccess: (projectID: number) => ( - createAction(ProjectsActionTypes.BACKUP_PROJECT_SUCCESS, { projectID }) - ), - backupProjectFailed: (projectID: number, error: any) => ( - createAction(ProjectsActionTypes.BACKUP_PROJECT_FAILED, { projectId: projectID, error }) - ), - restoreProject: () => createAction(ProjectsActionTypes.RESTORE_PROJECT), - restoreProjectSuccess: (projectID: number) => ( - createAction(ProjectsActionTypes.RESTORE_PROJECT_SUCCESS, { projectID }) - ), - restoreProjectFailed: (error: any) => ( - createAction(ProjectsActionTypes.RESTORE_PROJECT_FAILED, { error }) - ), }; export type ProjectActions = ActionUnion; @@ -190,31 +171,3 @@ export function deleteProjectAsync(projectInstance: any): ThunkAction { } }; } - -export function restoreProjectAsync(file: File): ThunkAction { - return async (dispatch: ActionCreator): Promise => { - dispatch(projectActions.restoreProject()); - try { - const projectInstance = await cvat.classes.Project.restore(file); - dispatch(projectActions.restoreProjectSuccess(projectInstance)); - } catch (error) { - dispatch(projectActions.restoreProjectFailed(error)); - } - }; -} - -export function backupProjectAsync(projectInstance: any): ThunkAction { - return async (dispatch: ActionCreator): Promise => { - dispatch(projectActions.backupProject(projectInstance.id)); - - try { - const url = await projectInstance.backup(); - const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; - downloadAnchor.href = url; - downloadAnchor.click(); - dispatch(projectActions.backupProjectSuccess(projectInstance.id)); - } catch (error) { - dispatch(projectActions.backupProjectFailed(projectInstance.id, error)); - } - }; -} diff --git a/cvat-ui/src/actions/share-actions.ts b/cvat-ui/src/actions/share-actions.ts index 25a4aed50f09..1ceed81e99c4 100644 --- a/cvat-ui/src/actions/share-actions.ts +++ b/cvat-ui/src/actions/share-actions.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -28,16 +29,16 @@ const shareActions = { export type ShareActions = ActionUnion; -export function loadShareDataAsync(directory: string, success: () => void, failure: () => void): ThunkAction { - return async (dispatch): Promise => { +export function loadShareDataAsync(directory: string): ThunkAction { + return async (dispatch): Promise => { try { dispatch(shareActions.loadShareData()); const values = await core.server.share(directory); - success(); dispatch(shareActions.loadShareDataSuccess(values as ShareFileInfo[], directory)); + return (values as ShareFileInfo[]); } catch (error) { - failure(); dispatch(shareActions.loadShareDataFailed(error)); + throw error; } }; } diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index cb9667755910..11c755c298c5 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -1,12 +1,14 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { AnyAction, Dispatch, ActionCreator } from 'redux'; import { ThunkAction } from 'redux-thunk'; -import { TasksQuery, CombinedState, Indexable } from 'reducers'; -import { getCVATStore } from 'cvat-store'; -import { getCore } from 'cvat-core-wrapper'; +import { + TasksQuery, CombinedState, Indexable, StorageLocation, +} from 'reducers'; +import { getCore, Storage } from 'cvat-core-wrapper'; import { getInferenceStatusAsync } from './models-actions'; const cvat = getCore(); @@ -15,15 +17,9 @@ export enum TasksActionTypes { GET_TASKS = 'GET_TASKS', GET_TASKS_SUCCESS = 'GET_TASKS_SUCCESS', GET_TASKS_FAILED = 'GET_TASKS_FAILED', - LOAD_ANNOTATIONS = 'LOAD_ANNOTATIONS', - LOAD_ANNOTATIONS_SUCCESS = 'LOAD_ANNOTATIONS_SUCCESS', - LOAD_ANNOTATIONS_FAILED = 'LOAD_ANNOTATIONS_FAILED', DELETE_TASK = 'DELETE_TASK', DELETE_TASK_SUCCESS = 'DELETE_TASK_SUCCESS', DELETE_TASK_FAILED = 'DELETE_TASK_FAILED', - CREATE_TASK = 'CREATE_TASK', - CREATE_TASK_STATUS_UPDATED = 'CREATE_TASK_STATUS_UPDATED', - CREATE_TASK_SUCCESS = 'CREATE_TASK_SUCCESS', CREATE_TASK_FAILED = 'CREATE_TASK_FAILED', UPDATE_TASK = 'UPDATE_TASK', UPDATE_TASK_SUCCESS = 'UPDATE_TASK_SUCCESS', @@ -32,12 +28,6 @@ export enum TasksActionTypes { UPDATE_JOB_SUCCESS = 'UPDATE_JOB_SUCCESS', UPDATE_JOB_FAILED = 'UPDATE_JOB_FAILED', HIDE_EMPTY_TASKS = 'HIDE_EMPTY_TASKS', - EXPORT_TASK = 'EXPORT_TASK', - EXPORT_TASK_SUCCESS = 'EXPORT_TASK_SUCCESS', - EXPORT_TASK_FAILED = 'EXPORT_TASK_FAILED', - IMPORT_TASK = 'IMPORT_TASK', - IMPORT_TASK_SUCCESS = 'IMPORT_TASK_SUCCESS', - IMPORT_TASK_FAILED = 'IMPORT_TASK_FAILED', SWITCH_MOVE_TASK_MODAL_VISIBLE = 'SWITCH_MOVE_TASK_MODAL_VISIBLE', } @@ -103,157 +93,6 @@ export function getTasksAsync(query: TasksQuery, updateQuery = true): ThunkActio }; } -function loadAnnotations(task: any, loader: any): AnyAction { - const action = { - type: TasksActionTypes.LOAD_ANNOTATIONS, - payload: { - task, - loader, - }, - }; - - return action; -} - -function loadAnnotationsSuccess(task: any): AnyAction { - const action = { - type: TasksActionTypes.LOAD_ANNOTATIONS_SUCCESS, - payload: { - task, - }, - }; - - return action; -} - -function loadAnnotationsFailed(task: any, error: any): AnyAction { - const action = { - type: TasksActionTypes.LOAD_ANNOTATIONS_FAILED, - payload: { - task, - error, - }, - }; - - return action; -} - -export function loadAnnotationsAsync( - task: any, - loader: any, - file: File, -): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - try { - const store = getCVATStore(); - const state: CombinedState = store.getState(); - if (state.tasks.activities.loads[task.id]) { - throw Error('Only one loading of annotations for a task allowed at the same time'); - } - dispatch(loadAnnotations(task, loader)); - await task.annotations.upload(file, loader); - } catch (error) { - dispatch(loadAnnotationsFailed(task, error)); - return; - } - - dispatch(loadAnnotationsSuccess(task)); - }; -} - -function importTask(): AnyAction { - const action = { - type: TasksActionTypes.IMPORT_TASK, - payload: {}, - }; - - return action; -} - -function importTaskSuccess(task: any): AnyAction { - const action = { - type: TasksActionTypes.IMPORT_TASK_SUCCESS, - payload: { - task, - }, - }; - - return action; -} - -function importTaskFailed(error: any): AnyAction { - const action = { - type: TasksActionTypes.IMPORT_TASK_FAILED, - payload: { - error, - }, - }; - - return action; -} - -export function importTaskAsync(file: File): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - try { - dispatch(importTask()); - const taskInstance = await cvat.classes.Task.import(file); - dispatch(importTaskSuccess(taskInstance)); - } catch (error) { - dispatch(importTaskFailed(error)); - } - }; -} - -function exportTask(taskID: number): AnyAction { - const action = { - type: TasksActionTypes.EXPORT_TASK, - payload: { - taskID, - }, - }; - - return action; -} - -function exportTaskSuccess(taskID: number): AnyAction { - const action = { - type: TasksActionTypes.EXPORT_TASK_SUCCESS, - payload: { - taskID, - }, - }; - - return action; -} - -function exportTaskFailed(taskID: number, error: Error): AnyAction { - const action = { - type: TasksActionTypes.EXPORT_TASK_FAILED, - payload: { - taskID, - error, - }, - }; - - return action; -} - -export function exportTaskAsync(taskInstance: any): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { - dispatch(exportTask(taskInstance.id)); - - try { - const url = await taskInstance.export(); - const downloadAnchor = window.document.getElementById('downloadAnchor') as HTMLAnchorElement; - downloadAnchor.href = url; - downloadAnchor.click(); - dispatch(exportTaskSuccess(taskInstance.id)); - } catch (error) { - dispatch(exportTaskFailed(taskInstance.id, error as Error)); - } - }; -} - function deleteTask(taskID: number): AnyAction { const action = { type: TasksActionTypes.DELETE_TASK, @@ -302,26 +141,6 @@ export function deleteTaskAsync(taskInstance: any): ThunkAction, { }; } -function createTask(): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK, - payload: {}, - }; - - return action; -} - -function createTaskSuccess(taskId: number): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK_SUCCESS, - payload: { - taskId, - }, - }; - - return action; -} - function createTaskFailed(error: any): AnyAction { const action = { type: TasksActionTypes.CREATE_TASK_FAILED, @@ -333,19 +152,9 @@ function createTaskFailed(error: any): AnyAction { return action; } -function createTaskUpdateStatus(status: string): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK_STATUS_UPDATED, - payload: { - status, - }, - }; - - return action; -} - -export function createTaskAsync(data: any): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { +export function createTaskAsync(data: any, onProgress?: (status: string) => void): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch): Promise => { const description: any = { name: data.basic.name, labels: data.labels, @@ -353,6 +162,8 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A use_zip_chunks: data.advanced.useZipChunks, use_cache: data.advanced.useCache, sorting_method: data.advanced.sortingMethod, + source_storage: new Storage(data.advanced.sourceStorage || { location: StorageLocation.LOCAL }).toJSON(), + target_storage: new Storage(data.advanced.targetStorage || { location: StorageLocation.LOCAL }).toJSON(), }; if (data.projectId) { @@ -402,7 +213,7 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A if (gitPlugin) { gitPlugin.callbacks.onStatusChange = (status: string): void => { - dispatch(createTaskUpdateStatus(status)); + onProgress?.(status); }; gitPlugin.data.task = taskInstance; gitPlugin.data.repos = data.advanced.repository; @@ -411,12 +222,10 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A } } - dispatch(createTask()); try { const savedTask = await taskInstance.save((status: string, progress: number): void => { - dispatch(createTaskUpdateStatus(status + (progress !== null ? ` ${Math.floor(progress * 100)}%` : ''))); + onProgress?.(status + (progress !== null ? ` ${Math.floor(progress * 100)}%` : '')); }); - dispatch(createTaskSuccess(savedTask.id)); return savedTask; } catch (error) { dispatch(createTaskFailed(error)); diff --git a/cvat-ui/src/actions/webhooks-actions.ts b/cvat-ui/src/actions/webhooks-actions.ts new file mode 100644 index 000000000000..1b20b1ee41c7 --- /dev/null +++ b/cvat-ui/src/actions/webhooks-actions.ts @@ -0,0 +1,113 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { getCore, Webhook } from 'cvat-core-wrapper'; +import { Dispatch, ActionCreator, Store } from 'redux'; +import { Indexable, WebhooksQuery } from 'reducers'; +import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; + +const cvat = getCore(); + +export enum WebhooksActionsTypes { + GET_WEBHOOKS = 'GET_WEBHOOKS', + GET_WEBHOOKS_SUCCESS = 'GET_WEBHOOKS_SUCCESS', + GET_WEBHOOKS_FAILED = 'GET_WEBHOOKS_FAILED', + CREATE_WEBHOOK = 'CREATE_WEBHOOK', + CREATE_WEBHOOK_SUCCESS = 'CREATE_WEBHOOK_SUCCESS', + CREATE_WEBHOOK_FAILED = 'CREATE_WEBHOOK_FAILED', + UPDATE_WEBHOOK = 'UPDATE_WEBHOOK', + UPDATE_WEBHOOK_SUCCESS = 'UPDATE_WEBHOOK_SUCCESS', + UPDATE_WEBHOOK_FAILED = 'UPDATE_WEBHOOK_FAILED', + DELETE_WEBHOOK = 'DELETE_WEBHOOK', + DELETE_WEBHOOK_SUCCESS = 'DELETE_WEBHOOK_SUCCESS', + DELETE_WEBHOOK_FAILED = 'DELETE_WEBHOOK_FAILED', +} + +const webhooksActions = { + getWebhooks: (query: WebhooksQuery) => createAction(WebhooksActionsTypes.GET_WEBHOOKS, { query }), + getWebhooksSuccess: (webhooks: Webhook[], count: number) => createAction( + WebhooksActionsTypes.GET_WEBHOOKS_SUCCESS, { webhooks, count }, + ), + getWebhooksFailed: (error: any) => createAction(WebhooksActionsTypes.GET_WEBHOOKS_FAILED, { error }), + createWebhook: () => createAction(WebhooksActionsTypes.CREATE_WEBHOOK), + createWebhookSuccess: (webhook: Webhook) => createAction(WebhooksActionsTypes.CREATE_WEBHOOK_SUCCESS, { webhook }), + createWebhookFailed: (error: any) => createAction(WebhooksActionsTypes.CREATE_WEBHOOK_FAILED, { error }), + updateWebhook: () => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK), + updateWebhookSuccess: (webhook: any) => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK_SUCCESS, { webhook }), + updateWebhookFailed: (error: any) => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK_FAILED, { error }), + deleteWebhook: () => createAction(WebhooksActionsTypes.DELETE_WEBHOOK), + deleteWebhookSuccess: () => createAction(WebhooksActionsTypes.DELETE_WEBHOOK_SUCCESS), + deleteWebhookFailed: (webhookID: number, error: any) => createAction( + WebhooksActionsTypes.DELETE_WEBHOOK_FAILED, { webhookID, error }, + ), +}; + +export const getWebhooksAsync = (query: WebhooksQuery): ThunkAction => ( + async (dispatch: ActionCreator): Promise => { + dispatch(webhooksActions.getWebhooks(query)); + + // We remove all keys with null values from the query + const filteredQuery = { ...query }; + for (const key of Object.keys(query)) { + if ((filteredQuery as Indexable)[key] === null) { + delete (filteredQuery as Indexable)[key]; + } + } + + let result = null; + try { + result = await cvat.webhooks.get(filteredQuery); + } catch (error) { + dispatch(webhooksActions.getWebhooksFailed(error)); + return; + } + + const array: Array = Array.from(result); + + dispatch(webhooksActions.getWebhooksSuccess(array, result.count)); + } +); + +export function createWebhookAsync(webhookData: Store): ThunkAction { + return async function (dispatch) { + const webhook = new cvat.classes.Webhook(webhookData); + dispatch(webhooksActions.createWebhook()); + + try { + const createdWebhook = await webhook.save(); + dispatch(webhooksActions.createWebhookSuccess(createdWebhook)); + } catch (error) { + dispatch(webhooksActions.createWebhookFailed(error)); + throw error; + } + }; +} + +export function updateWebhookAsync(webhook: Webhook): ThunkAction { + return async function (dispatch) { + dispatch(webhooksActions.updateWebhook()); + + try { + const updatedWebhook = await webhook.save(); + dispatch(webhooksActions.updateWebhookSuccess(updatedWebhook)); + } catch (error) { + dispatch(webhooksActions.updateWebhookFailed(error)); + throw error; + } + }; +} + +export function deleteWebhookAsync(webhook: Webhook): ThunkAction { + return async function (dispatch) { + try { + await webhook.delete(); + dispatch(webhooksActions.deleteWebhookSuccess()); + } catch (error) { + dispatch(webhooksActions.deleteWebhookFailed(webhook.id, error)); + throw error; + } + }; +} + +export type WebhooksActions = ActionUnion; diff --git a/cvat-ui/src/assets/multi-plus-icon.svg b/cvat-ui/src/assets/multi-plus-icon.svg new file mode 100644 index 000000000000..ec93171eff54 --- /dev/null +++ b/cvat-ui/src/assets/multi-plus-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cvat-ui/src/components/actions-menu/actions-menu.tsx b/cvat-ui/src/components/actions-menu/actions-menu.tsx index 0282aedfdb44..272393e90d17 100644 --- a/cvat-ui/src/components/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/components/actions-menu/actions-menu.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,8 +10,6 @@ import Modal from 'antd/lib/modal'; import { LoadingOutlined } from '@ant-design/icons'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; - -import LoadSubmenu from './load-submenu'; import { DimensionType } from '../../reducers'; interface Props { @@ -19,12 +18,10 @@ interface Props { bugTracker: string; loaders: any[]; dumpers: any[]; - loadActivity: string | null; inferenceIsActive: boolean; taskDimension: DimensionType; + backupIsActive: boolean; onClickMenu: (params: MenuInfo) => void; - onUploadAnnotations: (format: string, file: File) => void; - exportIsActive: boolean; } export enum Actions { @@ -34,7 +31,7 @@ export enum Actions { RUN_AUTO_ANNOTATION = 'run_auto_annotation', MOVE_TASK_TO_PROJECT = 'move_task_to_project', OPEN_BUG_TRACKER = 'open_bug_tracker', - EXPORT_TASK = 'export_task', + BACKUP_TASK = 'backup_task', } function ActionsMenuComponent(props: Props): JSX.Element { @@ -42,12 +39,8 @@ function ActionsMenuComponent(props: Props): JSX.Element { taskID, bugTracker, inferenceIsActive, - loaders, + backupIsActive, onClickMenu, - onUploadAnnotations, - loadActivity, - taskDimension, - exportIsActive, } = props; const onClickMenuWrapper = useCallback( @@ -79,38 +72,16 @@ function ActionsMenuComponent(props: Props): JSX.Element { return ( - {LoadSubmenu({ - loaders, - loadActivity, - onFileUpload: (format: string, file: File): void => { - if (file) { - Modal.confirm({ - title: 'Current annotation will be lost', - content: 'You are going to upload new annotations to this task. Continue?', - className: 'cvat-modal-content-load-task-annotation', - onOk: () => { - onUploadAnnotations(format, file); - }, - okButtonProps: { - type: 'primary', - danger: true, - }, - okText: 'Update', - }); - } - }, - menuKey: Actions.LOAD_TASK_ANNO, - taskDimension, - })} + Upload annotations Export task dataset {!!bugTracker && Open bug tracker} Automatic annotation } + key={Actions.BACKUP_TASK} + disabled={backupIsActive} + icon={backupIsActive && } > Backup Task diff --git a/cvat-ui/src/components/actions-menu/load-submenu.tsx b/cvat-ui/src/components/actions-menu/load-submenu.tsx deleted file mode 100644 index feda16b563b6..000000000000 --- a/cvat-ui/src/components/actions-menu/load-submenu.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (C) 2020-2022 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import React from 'react'; -import Menu from 'antd/lib/menu'; -import Upload from 'antd/lib/upload'; -import Button from 'antd/lib/button'; -import Text from 'antd/lib/typography/Text'; -import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; -import { DimensionType } from '../../reducers'; - -interface Props { - menuKey: string; - loaders: any[]; - loadActivity: string | null; - onFileUpload(format: string, file: File): void; - taskDimension: DimensionType; -} - -export default function LoadSubmenu(props: Props): JSX.Element { - const { - menuKey, loaders, loadActivity, onFileUpload, taskDimension, - } = props; - - return ( - - {loaders - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - .filter((loader: any): boolean => loader.dimension === taskDimension) - .map( - (loader: any): JSX.Element => { - const accept = loader.format - .split(',') - .map((x: string) => `.${x.trimStart()}`) - .join(', '); // add '.' to each extension in a list - const pending = loadActivity === loader.name; - const disabled = !loader.enabled || !!loadActivity; - const format = loader.name; - return ( - - { - onFileUpload(format, file); - return false; - }} - > - - - - ); - }, - )} - - ); -} diff --git a/cvat-ui/src/components/actions-menu/styles.scss b/cvat-ui/src/components/actions-menu/styles.scss index 25240c9c654d..1d05bb42b009 100644 --- a/cvat-ui/src/components/actions-menu/styles.scss +++ b/cvat-ui/src/components/actions-menu/styles.scss @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -17,7 +18,6 @@ } } -.cvat-menu-load-submenu-item, .cvat-menu-dump-submenu-item, .cvat-menu-export-submenu-item { > span[role='img'] { @@ -29,28 +29,12 @@ } } -.ant-menu-item.cvat-menu-load-submenu-item { - margin: 0; - padding: 0; - - > span > .ant-upload { - width: 100%; - height: 100%; - - > span > button { - width: 100%; - height: 100%; - text-align: left; - } - } -} - .cvat-menu-icon { font-size: 16px; margin-left: 8px; align-self: center; } -#cvat-export-task-loading { +#cvat-backup-task-loading { margin-left: 10; } diff --git a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx index b9cff4e05453..75d608140161 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/annotation-menu.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -14,9 +15,7 @@ import Collapse from 'antd/lib/collapse'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; - import CVATTooltip from 'components/common/cvat-tooltip'; -import LoadSubmenu from 'components/actions-menu/load-submenu'; import { getCore } from 'cvat-core-wrapper'; import { JobStage } from 'reducers'; @@ -24,12 +23,8 @@ const core = getCore(); interface Props { taskMode: string; - loaders: any[]; - dumpers: any[]; - loadActivity: string | null; jobInstance: any; onClickMenu(params: MenuInfo): void; - onUploadAnnotations(format: string, file: File): void; stopFrame: number; removeAnnotations(startnumber: number, endnumber: number, delTrackKeyframesOnly:boolean): void; setForceExitAnnotationFlag(forceExit: boolean): void; @@ -38,7 +33,7 @@ interface Props { export enum Actions { LOAD_JOB_ANNO = 'load_job_anno', - EXPORT_TASK_DATASET = 'export_task_dataset', + EXPORT_JOB_DATASET = 'export_job_dataset', REMOVE_ANNO = 'remove_anno', OPEN_TASK = 'open_task', FINISH_JOB = 'finish_job', @@ -47,13 +42,10 @@ export enum Actions { function AnnotationMenuComponent(props: Props & RouteComponentProps): JSX.Element { const { - loaders, - loadActivity, jobInstance, stopFrame, history, onClickMenu, - onUploadAnnotations, removeAnnotations, setForceExitAnnotationFlag, saveAnnotations, @@ -192,30 +184,8 @@ function AnnotationMenuComponent(props: Props & RouteComponentProps): JSX.Elemen return ( onClickMenuWrapper(params)} className='cvat-annotation-menu' selectable={false}> - {LoadSubmenu({ - loaders, - loadActivity, - onFileUpload: (format: string, file: File): void => { - if (file) { - Modal.confirm({ - title: 'Current annotation will be lost', - content: 'You are going to upload new annotations to this job. Continue?', - className: 'cvat-modal-content-load-job-annotation', - onOk: () => { - onUploadAnnotations(format, file); - }, - okButtonProps: { - type: 'primary', - danger: true, - }, - okText: 'Update', - }); - } - }, - menuKey: Actions.LOAD_JOB_ANNO, - taskDimension: jobInstance.dimension, - })} - Export task dataset + Upload annotations + Export job dataset Remove annotations * { + &:not(:first-child) { + margin-top: $grid-unit-size; + } + + width: 100%; + + .ant-upload { + width: 100%; + + button { + width: 100%; + } + } + } +} diff --git a/cvat-ui/src/components/common/cvat-dropdown-menu-paper/index.tsx b/cvat-ui/src/components/common/cvat-dropdown-menu-paper/index.tsx new file mode 100644 index 000000000000..d0dd5e3a6b19 --- /dev/null +++ b/cvat-ui/src/components/common/cvat-dropdown-menu-paper/index.tsx @@ -0,0 +1,12 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import './index.scss'; + +export default function DropdownMenuPaper({ children }: { children: React.ReactNode }): JSX.Element { + return ( +
    {children}
    + ); +} diff --git a/cvat-ui/src/components/create-project-page/create-project-content.tsx b/cvat-ui/src/components/create-project-page/create-project-content.tsx index accfc09a42a8..6e51117e1f3a 100644 --- a/cvat-ui/src/components/create-project-page/create-project-content.tsx +++ b/cvat-ui/src/components/create-project-page/create-project-content.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -12,17 +13,47 @@ import Select from 'antd/lib/select'; import { Col, Row } from 'antd/lib/grid'; import Text from 'antd/lib/typography/Text'; import Form, { FormInstance } from 'antd/lib/form'; +import Collapse from 'antd/lib/collapse'; import Button from 'antd/lib/button'; import Input from 'antd/lib/input'; import notification from 'antd/lib/notification'; - +import { StorageLocation } from 'reducers'; +import { createProjectAsync } from 'actions/projects-actions'; +import { Storage, StorageData } from 'cvat-core-wrapper'; import patterns from 'utils/validation-patterns'; import LabelsEditor from 'components/labels-editor/labels-editor'; -import { createProjectAsync } from 'actions/projects-actions'; +import SourceStorageField from 'components/storage/source-storage-field'; +import TargetStorageField from 'components/storage/target-storage-field'; import CreateProjectContext from './create-project.context'; const { Option } = Select; +interface AdvancedConfiguration { + sourceStorage: StorageData; + targetStorage: StorageData; + bug_tracker?: string | null; +} + +const initialValues: AdvancedConfiguration = { + bug_tracker: null, + sourceStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + targetStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, +}; + +interface AdvancedConfigurationProps { + formRef: RefObject; + sourceStorageLocation: StorageLocation; + targetStorageLocation: StorageLocation; + onChangeSourceStorageLocation?: (value: StorageLocation) => void; + onChangeTargetStorageLocation?: (value: StorageLocation) => void; +} + function NameConfigurationForm( { formRef, inputRef }: { formRef: RefObject, inputRef: RefObject }, @@ -99,9 +130,16 @@ function AdaptiveAutoAnnotationForm({ formRef }: { formRef: RefObject }): JSX.Element { +function AdvancedConfigurationForm(props: AdvancedConfigurationProps): JSX.Element { + const { + formRef, + sourceStorageLocation, + targetStorageLocation, + onChangeSourceStorageLocation, + onChangeTargetStorageLocation, + } = props; return ( -
    + + + + + + + + +
    ); } export default function CreateProjectContent(): JSX.Element { const [projectLabels, setProjectLabels] = useState([]); + const [sourceStorageLocation, setSourceStorageLocation] = useState(StorageLocation.LOCAL); + const [targetStorageLocation, setTargetStorageLocation] = useState(StorageLocation.LOCAL); const nameFormRef = useRef(null); const nameInputRef = useRef(null); const adaptiveAutoAnnotationFormRef = useRef(null); @@ -140,23 +198,32 @@ export default function CreateProjectContent(): JSX.Element { if (nameFormRef.current) nameFormRef.current.resetFields(); if (advancedFormRef.current) advancedFormRef.current.resetFields(); setProjectLabels([]); + setSourceStorageLocation(StorageLocation.LOCAL); + setTargetStorageLocation(StorageLocation.LOCAL); }; const focusForm = (): void => { nameInputRef.current?.focus(); }; - const sumbit = async (): Promise => { + const submit = async (): Promise => { try { let projectData: Record = {}; - if (nameFormRef.current && advancedFormRef.current) { + if (nameFormRef.current) { const basicValues = await nameFormRef.current.validateFields(); - const advancedValues = await advancedFormRef.current.validateFields(); + const advancedValues = advancedFormRef.current ? await advancedFormRef.current.validateFields() : {}; const adaptiveAutoAnnotationValues = await adaptiveAutoAnnotationFormRef.current?.validateFields(); + projectData = { ...projectData, ...advancedValues, name: basicValues.name, + source_storage: new Storage( + advancedValues.sourceStorage || { location: StorageLocation.LOCAL }, + ).toJSON(), + target_storage: new Storage( + advancedValues.targetStorage || { location: StorageLocation.LOCAL }, + ).toJSON(), }; if (adaptiveAutoAnnotationValues) { @@ -174,14 +241,14 @@ export default function CreateProjectContent(): JSX.Element { }; const onSubmitAndOpen = async (): Promise => { - const createdProject = await sumbit(); + const createdProject = await submit(); if (createdProject) { history.push(`/projects/${createdProject.id}`); } }; const onSubmitAndContinue = async (): Promise => { - const res = await sumbit(); + const res = await submit(); if (res) { resetForm(); notification.info({ @@ -216,7 +283,17 @@ export default function CreateProjectContent(): JSX.Element { /> - + + Advanced configuration}> + setSourceStorageLocation(value)} + onChangeTargetStorageLocation={(value: StorageLocation) => setTargetStorageLocation(value)} + /> + + diff --git a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx index 46818f597f06..4796af1c1079 100644 --- a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx @@ -1,12 +1,16 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { RefObject } from 'react'; import { Row, Col } from 'antd/lib/grid'; -import { PercentageOutlined } from '@ant-design/icons'; +import { PercentageOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import Input from 'antd/lib/input'; import Select from 'antd/lib/select'; +import Space from 'antd/lib/space'; +import Switch from 'antd/lib/switch'; +import Tooltip from 'antd/lib/tooltip'; import Radio from 'antd/lib/radio'; import Checkbox from 'antd/lib/checkbox'; import Form, { FormInstance, RuleObject, RuleRender } from 'antd/lib/form'; @@ -14,6 +18,13 @@ import Text from 'antd/lib/typography/Text'; import { Store } from 'antd/lib/form/interface'; import CVATTooltip from 'components/common/cvat-tooltip'; import patterns from 'utils/validation-patterns'; +import { StorageLocation } from 'reducers'; +import SourceStorageField from 'components/storage/source-storage-field'; +import TargetStorageField from 'components/storage/target-storage-field'; + +import { getCore, Storage, StorageData } from 'cvat-core-wrapper'; + +const core = getCore(); const { Option } = Select; @@ -40,6 +51,10 @@ export interface AdvancedConfiguration { useCache: boolean; copyData?: boolean; sortingMethod: SortingMethod; + useProjectSourceStorage: boolean; + useProjectTargetStorage: boolean; + sourceStorage: StorageData; + targetStorage: StorageData; } const initialValues: AdvancedConfiguration = { @@ -49,13 +64,33 @@ const initialValues: AdvancedConfiguration = { useCache: true, copyData: false, sortingMethod: SortingMethod.LEXICOGRAPHICAL, + useProjectSourceStorage: true, + useProjectTargetStorage: true, + + sourceStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + targetStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, }; interface Props { onSubmit(values: AdvancedConfiguration): void; + onChangeUseProjectSourceStorage(value: boolean): void; + onChangeUseProjectTargetStorage(value: boolean): void; + onChangeSourceStorageLocation: (value: StorageLocation) => void; + onChangeTargetStorageLocation: (value: StorageLocation) => void; installedGit: boolean; + projectId: number | null; + useProjectSourceStorage: boolean; + useProjectTargetStorage: boolean; activeFileManagerTab: string; - dumpers: [] + dumpers: []; + sourceStorageLocation: StorageLocation; + targetStorageLocation: StorageLocation; } function validateURL(_: RuleObject, value: string): Promise { @@ -146,10 +181,15 @@ class AdvancedConfigurationForm extends React.PureComponent { } public submit(): Promise { - const { onSubmit } = this.props; + const { onSubmit, projectId } = this.props; + if (this.formRef.current) { - return this.formRef.current.validateFields().then( - (values: Store): Promise => { + if (projectId) { + return Promise.all([ + core.projects.get({ id: projectId }), + this.formRef.current.validateFields(), + ]).then(([getProjectResponse, values]) => { + const [project] = getProjectResponse; const frameFilter = values.frameStep ? `step=${values.frameStep}` : undefined; const entries = Object.entries(values).filter( (entry: [string, unknown]): boolean => entry[0] !== frameFilter, @@ -158,10 +198,33 @@ class AdvancedConfigurationForm extends React.PureComponent { onSubmit({ ...((Object.fromEntries(entries) as any) as AdvancedConfiguration), frameFilter, + sourceStorage: values.useProjectSourceStorage ? + new Storage(project.sourceStorage || { location: StorageLocation.LOCAL }) : + new Storage(values.sourceStorage), + targetStorage: values.useProjectTargetStorage ? + new Storage(project.targetStorage || { location: StorageLocation.LOCAL }) : + new Storage(values.targetStorage), }); return Promise.resolve(); - }, - ); + }); + } + return this.formRef.current.validateFields() + .then( + (values: Store): Promise => { + const frameFilter = values.frameStep ? `step=${values.frameStep}` : undefined; + const entries = Object.entries(values).filter( + (entry: [string, unknown]): boolean => entry[0] !== frameFilter, + ); + + onSubmit({ + ...((Object.fromEntries(entries) as any) as AdvancedConfiguration), + frameFilter, + sourceStorage: new Storage(values.sourceStorage), + targetStorage: new Storage(values.targetStorage), + }); + return Promise.resolve(); + }, + ); } return Promise.reject(new Error('Form ref is empty')); @@ -201,15 +264,15 @@ class AdvancedConfigurationForm extends React.PureComponent { ]} help='Specify how to sort images. It is not relevant for videos.' > - - + + Lexicographical - - Natural - + + Natural + Predefined - - Random + + Random ); @@ -291,15 +354,19 @@ class AdvancedConfigurationForm extends React.PureComponent { private renderGitLFSBox(): JSX.Element { return ( - - - Use LFS (Large File Support): - - + + + + + Use LFS (Large File Support): + + + + ); } @@ -374,25 +441,37 @@ class AdvancedConfigurationForm extends React.PureComponent { private renderUzeZipChunks(): JSX.Element { return ( - - - Use zip/video chunks - - + + + + + Use zip/video chunks + + + + ); } private renderCreateTaskMethod(): JSX.Element { return ( - - - Use cache - - + + + + + Use cache + + + + ); } @@ -423,6 +502,48 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } + private renderSourceStorage(): JSX.Element { + const { + projectId, + useProjectSourceStorage, + sourceStorageLocation, + onChangeUseProjectSourceStorage, + onChangeSourceStorageLocation, + } = this.props; + return ( + + ); + } + + private renderTargetStorage(): JSX.Element { + const { + projectId, + useProjectTargetStorage, + targetStorageLocation, + onChangeUseProjectTargetStorage, + onChangeTargetStorageLocation, + } = this.props; + return ( + + ); + } + public render(): JSX.Element { const { installedGit, activeFileManagerTab } = this.props; return ( @@ -436,10 +557,8 @@ class AdvancedConfigurationForm extends React.PureComponent { ) : null} - {this.renderUzeZipChunks()} - - - {this.renderCreateTaskMethod()} + {this.renderUzeZipChunks()} + {this.renderCreateTaskMethod()} {this.renderImageQuality()} @@ -470,6 +589,14 @@ class AdvancedConfigurationForm extends React.PureComponent { {this.renderBugTracker()} + + + {this.renderSourceStorage()} + + + {this.renderTargetStorage()} + + ); } diff --git a/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx b/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx index a30f8b6aa1fb..01fdae326271 100644 --- a/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx @@ -1,37 +1,56 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { RefObject } from 'react'; import Input from 'antd/lib/input'; +import Text from 'antd/lib/typography/Text'; +import Tooltip from 'antd/lib/tooltip'; import Form, { FormInstance } from 'antd/lib/form'; -import { Store } from 'antd/lib/form/interface'; +import { QuestionCircleOutlined } from '@ant-design/icons'; export interface BaseConfiguration { name: string; } interface Props { - onSubmit(values: BaseConfiguration): void; + onChange(values: BaseConfiguration): void; + many: boolean; + exampleMultiTaskName?: string; } export default class BasicConfigurationForm extends React.PureComponent { private formRef: RefObject; private inputRef: RefObject; + private initialName: string; public constructor(props: Props) { super(props); this.formRef = React.createRef(); this.inputRef = React.createRef(); + + const { many } = this.props; + this.initialName = many ? '{{file_name}}' : ''; + } + + componentDidMount(): void { + const { onChange } = this.props; + onChange({ + name: this.initialName, + }); + } + + private handleChangeName(e: React.ChangeEvent): void { + const { onChange } = this.props; + onChange({ + name: e.target.value, + }); } public submit(): Promise { - const { onSubmit } = this.props; if (this.formRef.current) { - return this.formRef.current.validateFields().then((values: Store): Promise => { - onSubmit({ name: values.name }); - return Promise.resolve(); - }); + return this.formRef.current.validateFields(); } return Promise.reject(new Error('Form ref is empty')); @@ -50,6 +69,8 @@ export default class BasicConfigurationForm extends React.PureComponent { } public render(): JSX.Element { + const { many, exampleMultiTaskName } = this.props; + return (
    { message: 'Task name cannot be empty', }, ]} + initialValue={this.initialName} > - + this.handleChangeName(e)} + /> + {many ? ( + + ( + <> + You can substitute in the template: +
      +
    • + some_text - any text +
    • +
    • + {'{{'} + index + {'}}'} +  - index file in set +
    • +
    • + {'{{'} + file_name + {'}}'} +  - name of file +
    • +
    + Example:  + + {exampleMultiTaskName || 'Task name 1 - video_1.mp4'} + + + )} + > + When forming the name, a template is used. + {' '} + +
    +
    + ) : null}
    ); } diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index 49cc63f82f5a..286820a29638 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -6,22 +7,29 @@ import React, { RefObject } from 'react'; import { RouteComponentProps } from 'react-router'; import { withRouter } from 'react-router-dom'; import { Row, Col } from 'antd/lib/grid'; -import Alert from 'antd/lib/alert'; import Button from 'antd/lib/button'; import Collapse from 'antd/lib/collapse'; import notification from 'antd/lib/notification'; import Text from 'antd/lib/typography/Text'; +import Alert from 'antd/lib/alert'; // eslint-disable-next-line import/no-extraneous-dependencies import { ValidateErrorEntity } from 'rc-field-form/lib/interface'; - +import { StorageLocation } from 'reducers'; +import { getCore, Storage } from 'cvat-core-wrapper'; import ConnectedFileManager from 'containers/file-manager/file-manager'; import LabelsEditor from 'components/labels-editor/labels-editor'; import { Files } from 'components/file-manager/file-manager'; +import { getFileContentType, getContentTypeRemoteFile, getFileNameFromPath } from 'utils/files'; + import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import ProjectSearchField from './project-search-field'; import ProjectSubsetField from './project-subset-field'; +import MultiTasksProgress from './multi-task-progress'; import AdvancedConfigurationForm, { AdvancedConfiguration, SortingMethod } from './advanced-configuration-form'; +type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; +const core = getCore(); + export interface CreateTaskData { projectId: number | null; basic: BaseConfiguration; @@ -29,22 +37,28 @@ export interface CreateTaskData { advanced: AdvancedConfiguration; labels: any[]; files: Files; - activeFileManagerTab: string; + activeFileManagerTab: TabName; cloudStorageId: number | null; } interface Props { - onCreate: (data: CreateTaskData) => void; - status: string; - taskId: number | null; + onCreate: (data: CreateTaskData, onProgress?: (status: string, progress?: number) => void) => Promise; projectId: number | null; installedGit: boolean; - dumpers:[] + dumpers:[]; + many: boolean; } -type State = CreateTaskData; +type State = CreateTaskData & { + multiTasks: (CreateTaskData & { + status: 'pending' | 'progress' | 'failed' | 'completed' | 'cancelled'; + })[]; + uploadFileErrorMessage: string; + loading: boolean; + statusInProgressTask: string; +}; -const defaultState = { +const defaultState: State = { projectId: null, basic: { name: '', @@ -55,6 +69,16 @@ const defaultState = { useZipChunks: true, useCache: true, sortingMethod: SortingMethod.LEXICOGRAPHICAL, + sourceStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + targetStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + useProjectSourceStorage: true, + useProjectTargetStorage: true, }, labels: [], files: { @@ -65,6 +89,15 @@ const defaultState = { }, activeFileManagerTab: 'local', cloudStorageId: null, + multiTasks: [], + uploadFileErrorMessage: '', + loading: false, + statusInProgressTask: '', +}; + +const UploadFileErrorMessages = { + one: 'It can not be processed. You can upload an archive with images, a video or multiple images', + multi: 'It can not be processed. You can upload one or more videos', }; class CreateTaskContent extends React.PureComponent { @@ -89,6 +122,17 @@ class CreateTaskContent extends React.PureComponent ({ + advanced: { + ...state.advanced, + [field]: { + location: value, + }, + }, + })); + } + private resetState = (): void => { this.basicConfigurationComponent.current?.resetFields(); this.advancedConfigurationComponent.current?.resetFields(); @@ -107,23 +151,36 @@ class CreateTaskContent extends React.PureComponent { - const { activeFileManagerTab } = this.state; - const files = this.fileManagerContainer.getFiles(); - - this.setState({ - files, - }); + const { activeFileManagerTab, files } = this.state; if (activeFileManagerTab === 'cloudStorage') { this.setState({ cloudStorageId: this.fileManagerContainer.getCloudStorageId(), }); } - const totalLen = Object.keys(files).reduce((acc, key) => acc + files[key].length, 0); + const totalLen = Object.keys(files).reduce((acc, key: string) => acc + files[(key as TabName)].length, 0); return !!totalLen; }; + private startLoading = (): void => { + this.setState({ + loading: true, + }); + }; + + private stopLoading = (): void => { + this.setState({ + loading: false, + }); + }; + + private changeStatusInProgressTask = (status: string): void => { + this.setState({ + statusInProgressTask: status, + }); + }; + private handleProjectIdChange = (value: null | number): void => { const { projectId, subset } = this.state; @@ -134,7 +191,7 @@ class CreateTaskContent extends React.PureComponent { + private handleChangeBasicConfiguration = (values: BaseConfiguration): void => { this.setState({ basic: { ...values }, }); @@ -152,43 +209,153 @@ class CreateTaskContent extends React.PureComponent { - const values = this.state; + private changeFileManagerTab = (value: TabName): void => { this.setState({ - ...values, - activeFileManagerTab: key, + activeFileManagerTab: value, }); }; + private handleUseProjectSourceStorageChange = (value: boolean): void => { + this.setState((state) => ({ + advanced: { + ...state.advanced, + useProjectSourceStorage: value, + }, + })); + }; + + private handleUseProjectTargetStorageChange = (value: boolean): void => { + this.setState((state) => ({ + advanced: { + ...state.advanced, + useProjectTargetStorage: value, + }, + })); + }; + private focusToForm = (): void => { this.basicConfigurationComponent.current?.focus(); }; - private handleSubmitAndOpen = (): void => { - const { history } = this.props; + private handleUploadLocalFiles = (uploadedFiles: File[]): void => { + const { many } = this.props; + const { files } = this.state; - this.handleSubmit() - .then((createdTask) => { - const { id } = createdTask; - history.push(`/tasks/${id}`); - }) - .catch(() => {}); + let uploadFileErrorMessage = ''; + + if (!many && uploadedFiles.length > 1) { + uploadFileErrorMessage = uploadedFiles.every((it) => (getFileContentType(it) === 'image' || it.name === 'manifest.jsonl')) ? '' : UploadFileErrorMessages.one; + } else if (many) { + uploadFileErrorMessage = uploadedFiles.every((it) => getFileContentType(it) === 'video') ? '' : UploadFileErrorMessages.multi; + } + + this.setState({ + uploadFileErrorMessage, + }); + + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + local: uploadedFiles, + }, + }); + } }; - private handleSubmitAndContinue = (): void => { - this.handleSubmit() - .then(() => { - notification.info({ - message: 'The task has been created', - className: 'cvat-notification-create-task-success', - }); - }) - .then(this.resetState) - .then(this.focusToForm) - .catch(() => {}); + private handleUploadRemoteFiles = (urls: string[]): void => { + const { many } = this.props; + + const { files } = this.state; + const { length } = urls; + + let uploadFileErrorMessage = ''; + + try { + if (!many && length > 1) { + let index = 0; + while (index < length) { + const isImageFile = getContentTypeRemoteFile(urls[index]) === 'image'; + if (!isImageFile) { + uploadFileErrorMessage = UploadFileErrorMessages.one; + break; + } + index++; + } + } else if (many) { + let index = 0; + while (index < length) { + const isVideoFile = getContentTypeRemoteFile(urls[index]) === 'video'; + if (!isVideoFile) { + uploadFileErrorMessage = UploadFileErrorMessages.multi; + break; + } + index++; + } + } + } catch (err) { + uploadFileErrorMessage = `We can't process it. ${err}`; + } + + this.setState({ + uploadFileErrorMessage, + }); + + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + remote: urls, + }, + }); + } + }; + + private handleUploadShareFiles = (shareFiles: { + key: string; + type: string; + mime_type: string; + }[]): void => { + const { many } = this.props; + const { files } = this.state; + + let uploadFileErrorMessage = ''; + + if (!many && shareFiles.length > 1) { + uploadFileErrorMessage = shareFiles.every((it) => it.mime_type === 'image') ? + '' : UploadFileErrorMessages.one; + } else if (many) { + uploadFileErrorMessage = shareFiles.every((it) => it.mime_type === 'video') ? + '' : UploadFileErrorMessages.multi; + } + + this.setState({ + uploadFileErrorMessage, + }); + + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + share: shareFiles.map((it) => it.key), + }, + }); + } }; - private handleSubmit = (): Promise => new Promise((resolve, reject) => { + private handleUploadCloudStorageFiles = (cloudStorageFiles: string[]): void => { + const { files } = this.state; + + this.setState({ + files: { + ...files, + cloudStorage: cloudStorageFiles, + }, + }); + }; + + private validateBlocks = (): Promise => new Promise((resolve, reject) => { + const { projectId } = this.state; if (!this.validateLabelsOrProject()) { notification.error({ message: 'Could not create a task', @@ -220,15 +387,29 @@ class CreateTaskContent extends React.PureComponent { + const [project] = response; + const { advanced } = this.state; + this.handleSubmitAdvancedConfiguration({ + ...advanced, + sourceStorage: new Storage( + project.sourceStorage || { location: StorageLocation.LOCAL }, + ), + targetStorage: new Storage( + project.targetStorage || { location: StorageLocation.LOCAL }, + ), + }); + return Promise.resolve(); + }) + .catch((error: Error): void => { + throw new Error(`Couldn't fetch the project ${projectId} ${error.toString()}`); + }); + } return Promise.resolve(); }) - .then((): void => { - const { onCreate } = this.props; - return onCreate(this.state); - }) - .then((cratedTask) => { - resolve(cratedTask); - }) + .then(resolve) .catch((error: Error | ValidateErrorEntity): void => { notification.error({ message: 'Could not create a task', @@ -243,12 +424,256 @@ class CreateTaskContent extends React.PureComponent { + const { history } = this.props; + + this.validateBlocks() + .then(this.createOneTask) + .then((createdTask) => { + const { id } = createdTask; + history.push(`/tasks/${id}`); + }) + .catch(() => {}); + }; + + private handleSubmitAndContinue = (): void => { + this.validateBlocks() + .then(this.createOneTask) + .then(() => { + notification.info({ + message: 'The task has been created', + className: 'cvat-notification-create-task-success', + }); + }) + .then(this.resetState) + .then(this.focusToForm) + .catch(() => {}); + }; + + private createOneTask = (): Promise => { + const { onCreate } = this.props; + this.startLoading(); + return onCreate(this.state, this.changeStatusInProgressTask) + .finally(this.stopLoading); + }; + + private setStatusOneOfMultiTasks = async (index: number, status: string): Promise => { + const { multiTasks } = this.state; + const resultTask = { + ...multiTasks[index], + status, + }; + + return new Promise((resolve) => { + const newMultiTasks: any = [ + ...multiTasks.slice(0, index), + resultTask, + ...multiTasks.slice(index + 1), + ]; + this.setState({ + multiTasks: newMultiTasks, + }, resolve); + }); + }; + + private createOneOfMultiTasks = async (index: any): Promise => { + const { onCreate } = this.props; + const { multiTasks } = this.state; + const task = multiTasks[index]; + + if (task.status !== 'pending') return; + + await this.setStatusOneOfMultiTasks(index, 'progress'); + try { + await onCreate(task); + await this.setStatusOneOfMultiTasks(index, 'completed'); + } catch (err) { + console.warn(err); + await this.setStatusOneOfMultiTasks(index, 'failed'); + } + }; + + private createMultiTasks = async (): Promise => { + const { multiTasks } = this.state; + this.startLoading(); + const { length } = multiTasks; + let index = 0; + const queueSize = 1; + const promises = Array(queueSize) + .fill(undefined) + .map(async (): Promise => { + // eslint-disable-next-line no-constant-condition + while (true) { + index++; // preliminary increase is needed to avoid using the same index when queueSize > 1 + if (index > length) break; + await this.createOneOfMultiTasks(index - 1); + } + }); + await Promise.allSettled(promises); + this.stopLoading(); + }; + + private addMultiTasks = async (): Promise => new Promise((resolve) => { + const { + projectId, + subset, + advanced, + labels, + files: allFiles, + activeFileManagerTab, + cloudStorageId, + } = this.state; + + const files: (File | string)[] = allFiles[activeFileManagerTab]; + + this.setState({ + multiTasks: files.map((file, index) => ({ + projectId, + basic: { + name: this.getTaskName(index, activeFileManagerTab), + }, + subset, + advanced, + labels, + files: { + ...defaultState.files, + [activeFileManagerTab]: [file], + }, + activeFileManagerTab, + cloudStorageId, + status: 'pending', + } + )), + }, resolve); + }); + + private handleSubmitMutliTasks = (): void => { + this.validateBlocks() + .then(() => { + this.addMultiTasks(); + }) + .then(this.createMultiTasks) + .then(() => { + const { multiTasks } = this.state; + const countCompleted = multiTasks.filter((item) => item.status === 'completed').length; + const countFailed = multiTasks.filter((item) => item.status === 'failed').length; + const countCancelled = multiTasks.filter((item) => item.status === 'cancelled').length; + const countAll = multiTasks.length; + + notification.info({ + message: 'The tasks have been created', + description: + `Completed: ${countCompleted}, failed: ${countFailed},${countCancelled ? + ` cancelled: ${countCancelled},` : + ''} total: ${countAll}, `, + className: 'cvat-notification-create-task-success', + }); + }); + }; + + private handleCancelMultiTasks = (): void => { + const { multiTasks } = this.state; + let count = 0; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'pending') { + count++; + return { + ...it, + status: 'cancelled', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + notification.info({ + message: `Creation of ${count} tasks have been canceled`, + className: 'cvat-notification-create-task-success', + }); + }); + }; + + private handleOkMultiTasks = (): void => { + const { history, projectId } = this.props; + if (projectId) { + history.push(`/projects/${projectId}`); + } else { + history.push('/tasks/'); + } + }; + + private handleRetryCancelledMultiTasks = (): void => { + const { multiTasks } = this.state; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'cancelled') { + return { + ...it, + status: 'pending', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + this.createMultiTasks(); + }); + }; + + private handleRetryFailedMultiTasks = (): void => { + const { multiTasks } = this.state; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'failed') { + return { + ...it, + status: 'pending', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + this.createMultiTasks(); + }); + }; + + private getTaskName = (indexFile: number, fileManagerTabName: TabName, defaultFileName = ''): string => { + const { many } = this.props; + const { basic } = this.state; + const { files } = this.state; + const file = files[fileManagerTabName][indexFile]; + let fileName = defaultFileName; + switch (fileManagerTabName) { + case 'remote': + fileName = getFileNameFromPath(file as string) || defaultFileName; + break; + case 'share': + fileName = getFileNameFromPath(file as string) || defaultFileName; + break; + default: + fileName = (file as File)?.name || (file as string) || defaultFileName; + break; + } + return many ? + basic.name + .replaceAll('{{file_name}}', fileName) + .replaceAll('{{index}}', indexFile.toString()) : + basic.name; + }; + private renderBasicBlock(): JSX.Element { + const { many } = this.props; + const exampleMultiTaskName = many ? this.getTaskName(0, 'local', 'fileName.mp4') : ''; + return ( ); @@ -325,26 +750,59 @@ class CreateTaskContent extends React.PureComponent - * - Select files - { - this.fileManagerContainer = container; - }} - /> - + <> + + * + Select files + { + this.fileManagerContainer = container; + }} + /> + + { uploadFileErrorMessage ? ( + + + + ) : null } + ); } private renderAdvancedBlock(): JSX.Element { const { installedGit, dumpers } = this.props; - const { activeFileManagerTab } = this.state; + const { activeFileManagerTab, projectId } = this.state; + + const { + advanced: { + useProjectSourceStorage, + useProjectTargetStorage, + sourceStorage: { + location: sourceStorageLocation, + }, + targetStorage: { + location: targetStorageLocation, + }, + }, + } = this.state; return ( - + Advanced configuration}> { + this.handleChangeStorageLocation('sourceStorage', value); + }} + onChangeTargetStorageLocation={(value: StorageLocation) => { + this.handleChangeStorageLocation('targetStorage', value); + }} /> @@ -359,16 +830,21 @@ class CreateTaskContent extends React.PureComponent); + } return ( - - @@ -376,9 +852,50 @@ class CreateTaskContent extends React.PureComponent item.status === 'pending').length; + const countAll = items.length; + + if ((loading || countPending !== countAll) && currentFiles.length) { + return ( + + ); + } + + return ( + + + + + + ); + } + public render(): JSX.Element { - const { status } = this.props; - const loading = !!status && status !== 'CREATED' && status !== 'FAILED'; + const { many } = this.props; return ( @@ -394,7 +911,7 @@ class CreateTaskContent extends React.PureComponent - {loading ? : this.renderActions()} + {many ? this.renderFooterMutliTasks() : this.renderFooterSingleTask() } ); diff --git a/cvat-ui/src/components/create-task-page/create-task-page.tsx b/cvat-ui/src/components/create-task-page/create-task-page.tsx index 31d242d45d22..f6ad2fb049b9 100644 --- a/cvat-ui/src/components/create-task-page/create-task-page.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-page.tsx @@ -1,9 +1,10 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Modal from 'antd/lib/modal'; @@ -14,26 +15,30 @@ import TextArea from 'antd/lib/input/TextArea'; import CreateTaskContent, { CreateTaskData } from './create-task-content'; interface Props { - onCreate: (data: CreateTaskData) => void; - status: string; - error: string; - taskId: number | null; + onCreate: (data: CreateTaskData, onProgress?: (status: string) => void) => Promise; installedGit: boolean; dumpers: [] } export default function CreateTaskPage(props: Props): JSX.Element { const { - error, status, taskId, onCreate, installedGit, dumpers, + onCreate, installedGit, dumpers, } = props; const location = useLocation(); + const [error, setError] = useState(''); let projectId = null; const params = new URLSearchParams(location.search); if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) { projectId = +(params.get('projectId') as string); } + const many = params.get('many') === 'true'; + const handleCreate: typeof onCreate = (...onCreateParams) => onCreate(...onCreateParams) + .catch((err) => { + setError(err.toString()); + throw err; + }); useEffect(() => { if (error) { @@ -75,12 +80,11 @@ export default function CreateTaskPage(props: Props): JSX.Element { Create a new task diff --git a/cvat-ui/src/components/create-task-page/multi-task-progress.tsx b/cvat-ui/src/components/create-task-page/multi-task-progress.tsx new file mode 100644 index 000000000000..c2f2df9305b4 --- /dev/null +++ b/cvat-ui/src/components/create-task-page/multi-task-progress.tsx @@ -0,0 +1,159 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Alert from 'antd/lib/alert'; +import Progress from 'antd/lib/progress'; +import Row from 'antd/lib/row'; +import Col from 'antd/lib/col'; +import Button from 'antd/lib/button'; +import Collapse from 'antd/lib/collapse'; +import Text from 'antd/lib/typography/Text'; +import List from 'antd/lib/list'; + +interface Props { + tasks: any[]; + onCancel: () => void; + onOk: () => void; + onRetryFailedTasks: () => void; + onRetryCancelledTasks: () => void; +} + +export default function MultiTasksProgress(props: Props): JSX.Element { + const { + tasks: items, + onOk, + onCancel, + onRetryFailedTasks, + onRetryCancelledTasks, + } = props; + let alertType: any = 'info'; + + const countPending = items.filter((item) => item.status === 'pending').length; + const countProgress = items.filter((item) => item.status === 'progress').length; + const countCompleted = items.filter((item) => item.status === 'completed').length; + const countFailed = items.filter((item) => item.status === 'failed').length; + const countCancelled = items.filter((item) => item.status === 'cancelled').length; + const countAll = items.length; + const percent = countAll ? + Math.ceil(((countAll - (countPending + countProgress)) / countAll) * 100) : + 0; + + const failedFiles: string[] = percent === 100 && countFailed ? + items.filter((item) => item.status === 'failed') + .map((item): string => { + const tabs = Object.keys(item.files); + const itemType = tabs.find((key) => (item.files[key][0])) || 'local'; + return item.files[itemType][0]?.name || item.files[itemType][0] || ''; + }) + .filter(Boolean) : + []; + + if (percent === 100) { + if (countFailed === countAll) { + alertType = 'error'; + } else if (countFailed) { + alertType = 'warning'; + } + } + + return ( + + {percent === 100 ? ( + + + Finished + + + ) : null} + + + {`Pending: ${countPending} `} + + + {`Progress: ${countProgress} `} + + + {`Completed: ${countCompleted} `} + + + {`Failed: ${countFailed} `} + + {countCancelled ? ({`Cancelled: ${countCancelled} `}) : null} + + {`Total: ${countAll}.`} + + + +
    + {percent === 100 && countFailed ? ( + + + + Failed files + + )} + key='appearance' + > + { item }} + /> + + + + ) : null } + + {percent === 100 ? + ( + <> + + + + { + countCancelled ? ( + + + + ) : null + } + + + + + ) : ( + + + + )} + + + )} + /> + ); +} diff --git a/cvat-ui/src/components/create-task-page/styles.scss b/cvat-ui/src/components/create-task-page/styles.scss index 6cef28d30a9a..c8c8896f10bd 100644 --- a/cvat-ui/src/components/create-task-page/styles.scss +++ b/cvat-ui/src/components/create-task-page/styles.scss @@ -38,4 +38,8 @@ width: 100%; } } + + .cvat-settings-switch { + display: table-cell; + } } diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 114acd7d9a48..f38a0b01b9e5 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -24,6 +25,9 @@ import GlobalErrorBoundary from 'components/global-error-boundary/global-error-b import ShortcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; import ExportDatasetModal from 'components/export-dataset/export-dataset-modal'; +import ExportBackupModal from 'components/export-backup/export-backup-modal'; +import ImportDatasetModal from 'components/import-dataset/import-dataset-modal'; +import ImportBackupModal from 'components/import-backup/import-backup-modal'; import ModelsPageContainer from 'containers/models-page/models-page'; import JobsPageComponent from 'components/jobs-page/jobs-page'; @@ -44,6 +48,10 @@ import OrganizationPage from 'components/organization-page/organization-page'; import CreateOrganizationComponent from 'components/create-organization-page/create-organization-page'; import { ShortcutsContextProvider } from 'components/shortcuts.context'; +import WebhooksPage from 'components/webhooks-page/webhooks-page'; +import CreateWebhookPage from 'components/setup-webhook-pages/create-webhook-page'; +import UpdateWebhookPage from 'components/setup-webhook-pages/update-webhook-page'; + import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import { getCore } from 'cvat-core-wrapper'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; @@ -366,6 +374,7 @@ class CVATApplication extends React.PureComponent + @@ -387,6 +396,9 @@ class CVATApplication extends React.PureComponent + + + {isModelPluginActive && ( @@ -399,6 +411,9 @@ class CVATApplication extends React.PureComponent {/* eslint-disable-next-line */} + + + {/* eslint-disable-next-line */}
    diff --git a/cvat-ui/src/components/export-backup/export-backup-modal.tsx b/cvat-ui/src/components/export-backup/export-backup-modal.tsx new file mode 100644 index 000000000000..8d00bfe02e96 --- /dev/null +++ b/cvat-ui/src/components/export-backup/export-backup-modal.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import Notification from 'antd/lib/notification'; +import Text from 'antd/lib/typography/Text'; +import Input from 'antd/lib/input'; +import Form from 'antd/lib/form'; +import { CombinedState, StorageLocation } from 'reducers'; +import { exportActions, exportBackupAsync } from 'actions/export-actions'; +import { getCore, Storage, StorageData } from 'cvat-core-wrapper'; + +import TargetStorageField from 'components/storage/target-storage-field'; + +const core = getCore(); + +type FormValues = { + customName: string | undefined; + targetStorage: StorageData; + useProjectTargetStorage: boolean; +}; + +const initialValues: FormValues = { + customName: undefined, + targetStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + useProjectTargetStorage: true, +}; + +function ExportBackupModal(): JSX.Element { + const dispatch = useDispatch(); + const [form] = Form.useForm(); + const [instanceType, setInstanceType] = useState(''); + const [useDefaultStorage, setUseDefaultStorage] = useState(true); + const [storageLocation, setStorageLocation] = useState(StorageLocation.LOCAL); + const [defaultStorageLocation, setDefaultStorageLocation] = useState(StorageLocation.LOCAL); + const [defaultStorageCloudId, setDefaultStorageCloudId] = useState(null); + const [helpMessage, setHelpMessage] = useState(''); + + const instanceT = useSelector((state: CombinedState) => state.export.instanceType); + const instance = useSelector((state: CombinedState) => { + if (!instanceT) { + return null; + } + return state.export[`${instanceT}s` as 'projects' | 'tasks']?.backup?.modalInstance; + }); + + useEffect(() => { + if (instance instanceof core.classes.Project) { + setInstanceType(`project #${instance.id}`); + } else if (instance instanceof core.classes.Task) { + setInstanceType(`task #${instance.id}`); + } + }, [instance]); + + useEffect(() => { + if (instance) { + setDefaultStorageLocation(instance.targetStorage?.location || StorageLocation.LOCAL); + setDefaultStorageCloudId(instance.targetStorage?.cloudStorageId || null); + } + }, [instance]); + + useEffect(() => { + // eslint-disable-next-line prefer-template + const message = `Export backup to ${(defaultStorageLocation) ? defaultStorageLocation.split('_')[0] : 'local'} ` + + `storage ${(defaultStorageCloudId) ? `№${defaultStorageCloudId}` : ''}`; + setHelpMessage(message); + }, [defaultStorageLocation, defaultStorageCloudId]); + + const closeModal = (): void => { + setUseDefaultStorage(true); + setStorageLocation(StorageLocation.LOCAL); + form.resetFields(); + dispatch(exportActions.closeExportBackupModal(instance)); + }; + + const handleExport = useCallback( + (values: FormValues): void => { + dispatch( + exportBackupAsync( + instance, + new Storage({ + location: useDefaultStorage ? defaultStorageLocation : values.targetStorage?.location, + cloudStorageId: useDefaultStorage ? ( + defaultStorageCloudId + ) : ( + values.targetStorage?.cloudStorageId + ), + }), + useDefaultStorage, + values.customName ? `${values.customName}.zip` : undefined, + ), + ); + closeModal(); + Notification.info({ + message: 'Backup export started', + description: + 'Backup export was started. ' + + 'Download will start automatically as soon as the file is ready.', + className: 'cvat-notification-notice-export-backup-start', + }); + }, + [instance, useDefaultStorage, defaultStorageLocation, defaultStorageCloudId], + ); + + return ( + {`Export ${instanceType}`}} + visible={!!instance} + onCancel={closeModal} + onOk={() => form.submit()} + className={`cvat-modal-export-${instanceType.split(' ')[0]}`} + destroyOnClose + > +
    + Custom name} name='customName'> + + + setUseDefaultStorage(value)} + onChangeLocationValue={(value: StorageLocation) => setStorageLocation(value)} + /> + +
    + ); +} + +export default React.memo(ExportBackupModal); diff --git a/cvat-ui/src/components/export-backup/styles.scss b/cvat-ui/src/components/export-backup/styles.scss new file mode 100644 index 000000000000..92c21f8e565f --- /dev/null +++ b/cvat-ui/src/components/export-backup/styles.scss @@ -0,0 +1,13 @@ +// Copyright (c) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +@import '../../base.scss'; + +.cvat-modal-export-option-item > .ant-select-item-option-content, +.cvat-modal-export-select .ant-select-selection-item { + > span[role='img'] { + color: $info-icon-color; + margin-right: $grid-unit-size; + } +} diff --git a/cvat-ui/src/components/export-dataset/export-dataset-modal.tsx b/cvat-ui/src/components/export-dataset/export-dataset-modal.tsx index 315c394cdfad..675796fd306c 100644 --- a/cvat-ui/src/components/export-dataset/export-dataset-modal.tsx +++ b/cvat-ui/src/components/export-dataset/export-dataset-modal.tsx @@ -1,22 +1,24 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; import React, { useState, useEffect, useCallback } from 'react'; +import { connect, useDispatch } from 'react-redux'; import Modal from 'antd/lib/modal'; import Notification from 'antd/lib/notification'; -import { useSelector, useDispatch } from 'react-redux'; import { DownloadOutlined, LoadingOutlined } from '@ant-design/icons'; import Text from 'antd/lib/typography/Text'; import Select from 'antd/lib/select'; -import Checkbox from 'antd/lib/checkbox'; import Input from 'antd/lib/input'; import Form from 'antd/lib/form'; - -import { CombinedState } from 'reducers'; +import Switch from 'antd/lib/switch'; +import Space from 'antd/lib/space'; +import TargetStorageField from 'components/storage/target-storage-field'; +import { CombinedState, StorageLocation } from 'reducers'; import { exportActions, exportDatasetAsync } from 'actions/export-actions'; -import { getCore } from 'cvat-core-wrapper'; +import { getCore, Storage, StorageData } from 'cvat-core-wrapper'; const core = getCore(); @@ -24,43 +26,94 @@ type FormValues = { selectedFormat: string | undefined; saveImages: boolean; customName: string | undefined; + targetStorage: StorageData; + useProjectTargetStorage: boolean; +}; + +const initialValues: FormValues = { + selectedFormat: undefined, + saveImages: false, + customName: undefined, + targetStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + useProjectTargetStorage: true, }; -function ExportDatasetModal(): JSX.Element { +function ExportDatasetModal(props: StateToProps): JSX.Element { + const { + dumpers, + instance, + current, + } = props; + const [instanceType, setInstanceType] = useState(''); - const [activities, setActivities] = useState([]); + + const [useDefaultTargetStorage, setUseDefaultTargetStorage] = useState(true); const [form] = Form.useForm(); + const [targetStorage, setTargetStorage] = useState({ + location: StorageLocation.LOCAL, + }); + const [defaultStorageLocation, setDefaultStorageLocation] = useState(StorageLocation.LOCAL); + const [defaultStorageCloudId, setDefaultStorageCloudId] = useState(null); + const [helpMessage, setHelpMessage] = useState(''); const dispatch = useDispatch(); - const instance = useSelector((state: CombinedState) => state.export.instance); - const modalVisible = useSelector((state: CombinedState) => state.export.modalVisible); - const dumpers = useSelector((state: CombinedState) => state.formats.annotationFormats.dumpers); - const { tasks: taskExportActivities, projects: projectExportActivities } = useSelector( - (state: CombinedState) => state.export, - ); - const initActivities = (): void => { + useEffect(() => { if (instance instanceof core.classes.Project) { setInstanceType(`project #${instance.id}`); - setActivities(projectExportActivities[instance.id] || []); - } else if (instance) { - const taskID = instance instanceof core.classes.Task ? instance.id : instance.taskId; - setInstanceType(`task #${taskID}`); - setActivities(taskExportActivities[taskID] || []); + } else if (instance instanceof core.classes.Task || instance instanceof core.classes.Job) { + if (instance instanceof core.classes.Task) { + setInstanceType(`task #${instance.id}`); + } else { + setInstanceType(`job #${instance.id}`); + } if (instance.mode === 'interpolation' && instance.dimension === '2d') { form.setFieldsValue({ selectedFormat: 'CVAT for video 1.1' }); } else if (instance.mode === 'annotation' && instance.dimension === '2d') { form.setFieldsValue({ selectedFormat: 'CVAT for images 1.1' }); } } - }; + }, [instance]); useEffect(() => { - initActivities(); - }, [instance?.id, instance instanceof core.classes.Project, taskExportActivities, projectExportActivities]); + if (instance) { + if (instance instanceof core.classes.Project || instance instanceof core.classes.Task) { + setDefaultStorageLocation(instance.targetStorage?.location || StorageLocation.LOCAL); + setDefaultStorageCloudId(instance.targetStorage?.cloudStorageId || null); + } else { + core.tasks.get({ id: instance.taskId }) + .then((response: any) => { + if (response.length) { + const [taskInstance] = response; + setDefaultStorageLocation(taskInstance.targetStorage?.location || StorageLocation.LOCAL); + setDefaultStorageCloudId(taskInstance.targetStorage?.cloudStorageId || null); + } + }) + .catch((error: Error) => { + if ((error as any).code !== 403) { + Notification.error({ + message: `Could not fetch the task ${instance.taskId}`, + description: error.toString(), + }); + } + }); + } + } + }, [instance]); + + useEffect(() => { + // eslint-disable-next-line prefer-template + setHelpMessage(`Export to ${(defaultStorageLocation) ? defaultStorageLocation.split('_')[0] : 'local'} ` + + `storage ${(defaultStorageCloudId) ? `№${defaultStorageCloudId}` : ''}`); + }, [defaultStorageLocation, defaultStorageCloudId]); const closeModal = (): void => { + setUseDefaultTargetStorage(true); + setTargetStorage({ location: StorageLocation.LOCAL }); form.resetFields(); - dispatch(exportActions.closeExportModal()); + dispatch(exportActions.closeExportDatasetModal(instance)); }; const handleExport = useCallback( @@ -70,26 +123,32 @@ function ExportDatasetModal(): JSX.Element { exportDatasetAsync( instance, values.selectedFormat as string, - values.customName ? `${values.customName}.zip` : '', values.saveImages, + useDefaultTargetStorage, + useDefaultTargetStorage ? new Storage({ + location: defaultStorageLocation, + cloudStorageId: defaultStorageCloudId, + }) : new Storage(targetStorage), + values.customName ? `${values.customName}.zip` : null, ), ); closeModal(); + const resource = values.saveImages ? 'Dataset' : 'Annotations'; Notification.info({ - message: 'Dataset export started', + message: `${resource} export started`, description: - `Dataset export was started for ${instanceType}. ` + - 'Download will start automatically as soon as the dataset is ready.', + `${resource} export was started for ${instanceType}. ` + + `Download will start automatically as soon as the ${resource} is ready.`, className: `cvat-notification-notice-export-${instanceType.split(' ')[0]}-start`, }); }, - [instance, instanceType], + [instance, instanceType, useDefaultTargetStorage, defaultStorageLocation, defaultStorageCloudId, targetStorage], ); return ( {`Export ${instanceType} as a dataset`}} + visible={!!instance} onCancel={closeModal} onOk={() => form.submit()} className={`cvat-modal-export-${instanceType.split(' ')[0]}`} @@ -98,20 +157,13 @@ function ExportDatasetModal(): JSX.Element {
    Export format} rules={[{ required: true, message: 'Format must be selected' }]} > + setUseDefaultTargetStorage(value)} + onChangeStorage={(value: StorageData) => setTargetStorage(value)} + onChangeLocationValue={(value: StorageLocation) => { + setTargetStorage({ location: value }); + }} + />
    ); } -export default React.memo(ExportDatasetModal); +interface StateToProps { + dumpers: any; + instance: any; + current: any; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { instanceType } = state.export; + const instance = !instanceType ? null : ( + state.export[`${instanceType}s` as 'projects' | 'tasks' | 'jobs'] + ).dataset.modalInstance; + + return { + instance, + current: !instanceType ? [] : ( + state.export[`${instanceType}s` as 'projects' | 'tasks' | 'jobs'] + ).dataset.current[instance.id], + dumpers: state.formats.annotationFormats.dumpers, + }; +} + +export default connect(mapStateToProps)(ExportDatasetModal); diff --git a/cvat-ui/src/components/export-dataset/styles.scss b/cvat-ui/src/components/export-dataset/styles.scss index 40dba3475303..ca37813e2e2e 100644 --- a/cvat-ui/src/components/export-dataset/styles.scss +++ b/cvat-ui/src/components/export-dataset/styles.scss @@ -11,3 +11,7 @@ margin-right: $grid-unit-size; } } + +.cvat-modal-export-switch-use-default-storage { + display: table-cell; +} diff --git a/cvat-ui/src/components/file-manager/cloud-storages-tab.tsx b/cvat-ui/src/components/file-manager/cloud-storages-tab.tsx index f292c4ba6f32..d78c439942b1 100644 --- a/cvat-ui/src/components/file-manager/cloud-storages-tab.tsx +++ b/cvat-ui/src/components/file-manager/cloud-storages-tab.tsx @@ -1,20 +1,14 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; import React, { useEffect, useState } from 'react'; import Form from 'antd/lib/form'; -import notification from 'antd/lib/notification'; -import AutoComplete from 'antd/lib/auto-complete'; -import Input from 'antd/lib/input'; -import { debounce } from 'lodash'; - import Select from 'antd/lib/select'; -import { getCore } from 'cvat-core-wrapper'; import { CloudStorage } from 'reducers'; -import { AzureProvider, GoogleCloudProvider, S3Provider } from 'icons'; -import { ProviderType } from 'utils/enums'; +import SelectCloudStorage from 'components/select-cloud-storage/select-cloud-storage'; import CloudStorageFiles from './cloud-storages-files'; interface Props { @@ -27,61 +21,15 @@ interface Props { onSelectCloudStorage: (cloudStorageId: number | null) => void; } -async function searchCloudStorages(filter: Record): Promise { - try { - const data = await getCore().cloudStorages.get(filter); - return data; - } catch (error) { - notification.error({ - message: 'Could not fetch a list of cloud storages', - description: error.toString(), - }); - } - - return []; -} - const { Option } = Select; -const searchCloudStoragesWrapper = debounce((phrase, setList) => { - const filter = { - filter: JSON.stringify({ - and: [{ - '==': [{ var: 'display_name' }, phrase], - }], - }), - }; - searchCloudStorages(filter).then((list) => { - setList(list); - }); -}, 500); - export default function CloudStorageTab(props: Props): JSX.Element { const { searchPhrase, setSearchPhrase } = props; - const [initialList, setInitialList] = useState([]); - const [list, setList] = useState([]); const { formRef, cloudStorage, selectedFiles, onSelectFiles, onSelectCloudStorage, } = props; const [selectedManifest, setSelectedManifest] = useState(null); - useEffect(() => { - searchCloudStorages({}).then((data) => { - setInitialList(data); - if (!list.length) { - setList(data); - } - }); - }, []); - - useEffect(() => { - if (!searchPhrase) { - setList(initialList); - } else { - searchCloudStoragesWrapper(searchPhrase, setList); - } - }, [searchPhrase, initialList]); - useEffect(() => { if (cloudStorage) { setSelectedManifest(cloudStorage.manifests[0]); @@ -94,67 +42,15 @@ export default function CloudStorageTab(props: Props): JSX.Element { } }, [selectedManifest]); - const onBlur = (): void => { - if (!searchPhrase && cloudStorage) { - onSelectCloudStorage(null); - } else if (searchPhrase) { - const potentialStorages = list.filter((_cloudStorage) => _cloudStorage.displayName.includes(searchPhrase)); - if (potentialStorages.length === 1) { - const potentialStorage = potentialStorages[0]; - setSearchPhrase(potentialStorage.displayName); - // eslint-disable-next-line prefer-destructuring - potentialStorage.manifestPath = potentialStorage.manifests[0]; - onSelectCloudStorage(potentialStorage); - } - } - }; - return (
    - - { - setSearchPhrase(phrase); - }} - options={list.map((_cloudStorage) => ({ - value: _cloudStorage.id.toString(), - label: ( - - {_cloudStorage.providerType === ProviderType.AWS_S3_BUCKET && } - {_cloudStorage.providerType === ProviderType.AZURE_CONTAINER && } - { - _cloudStorage.providerType === ProviderType.GOOGLE_CLOUD_STORAGE && - - } - {_cloudStorage.displayName} - - ), - }))} - onSelect={(value: string) => { - const selectedCloudStorage = - list.filter((_cloudStorage: CloudStorage) => _cloudStorage.id === +value)[0] || null; - // eslint-disable-next-line prefer-destructuring - selectedCloudStorage.manifestPath = selectedCloudStorage.manifests[0]; - onSelectCloudStorage(selectedCloudStorage); - setSearchPhrase(selectedCloudStorage?.displayName || ''); - }} - allowClear - > - - - + {cloudStorage ? ( void, failure: () => void) => void; + treeData: (TreeNodeNormal & { mime_type: string })[]; + share: any; + many: boolean; + onLoadData: (key: string) => Promise; onChangeActiveKey(key: string): void; + onUploadLocalFiles(files: File[]): void; + onUploadRemoteFiles(urls: string[]): void; + onUploadShareFiles(keys: string[]): Promise; + onUploadCloudStorageFiles(cloudStorageFiles: string[]): void; } export class FileManager extends React.PureComponent { @@ -48,6 +55,7 @@ export class FileManager extends React.PureComponent { public constructor(props: Props) { super(props); this.cloudStorageTabFormRef = React.createRef(); + const { onLoadData } = this.props; this.state = { files: { @@ -62,17 +70,19 @@ export class FileManager extends React.PureComponent { active: 'local', }; - this.loadData('/'); + onLoadData('/'); } - private onSelectCloudStorageFiles = (cloudStorageFiles: string[]): void => { + private handleUploadCloudStorageFiles = (cloudStorageFiles: string[]): void => { const { files } = this.state; + const { onUploadCloudStorageFiles } = this.props; this.setState({ files: { ...files, cloudStorage: cloudStorageFiles, }, }); + onUploadCloudStorageFiles(cloudStorageFiles); }; public getCloudStorageId(): number | null { @@ -90,14 +100,6 @@ export class FileManager extends React.PureComponent { }; } - private loadData = (key: string): Promise => new Promise((resolve, reject): void => { - const { onLoadData } = this.props; - - const success = (): void => resolve(); - const failure = (): void => reject(); - onLoadData(key, success, failure); - }); - public reset(): void { const { active } = this.state; if (active === 'cloudStorage') { @@ -118,65 +120,43 @@ export class FileManager extends React.PureComponent { } private renderLocalSelector(): JSX.Element { + const { many, onUploadLocalFiles } = this.props; const { files } = this.state; return ( - { + { this.setState({ files: { ...files, local: newLocalFiles, }, }); + onUploadLocalFiles(newLocalFiles); return false; }} - > -

    - -

    -

    Click or drag files to this area

    -

    Support for a bulk images or a single video

    -
    - {files.local.length >= 5 && ( - <> -
    - {`${files.local.length} files selected`} - - )} + />
    ); } private renderShareSelector(): JSX.Element { - function renderTreeNodes(data: TreeNodeNormal[]): JSX.Element[] { + function getTreeNodes(data: TreeNodeNormal[]): TreeNodeNormal[] { // sort alphabetically - data.sort((a: TreeNodeNormal, b: TreeNodeNormal): number => ( - a.key.toLocaleString().localeCompare(b.key.toLocaleString()))); - return data.map((item: TreeNodeNormal) => { - if (item.children) { - return ( - - {renderTreeNodes(item.children)} - - ); - } - - return ; - }); + return data + .sort((a: TreeNodeNormal, b: TreeNodeNormal): number => ( + a.key.toLocaleString().localeCompare(b.key.toLocaleString()))) + .map((it) => ({ + ...it, + children: it.children ? getTreeNodes(it.children) : undefined, + })); } const { SHARE_MOUNT_GUIDE_URL } = consts; - const { treeData } = this.props; + const { treeData, onUploadShareFiles, onLoadData } = this.props; const { expandedKeys, files } = this.state; return ( @@ -190,7 +170,7 @@ export class FileManager extends React.PureComponent { checkStrictly={false} expandedKeys={expandedKeys} checkedKeys={files.share} - loadData={(event: EventDataNode): Promise => this.loadData(event.key.toLocaleString())} + loadData={(event: EventDataNode): Promise => onLoadData(event.key.toLocaleString())} onExpand={(newExpandedKeys: ReactText[]): void => { this.setState({ expandedKeys: newExpandedKeys.map((text: ReactText): string => text.toLocaleString()), @@ -212,10 +192,10 @@ export class FileManager extends React.PureComponent { share: keys, }, }); + onUploadShareFiles(keys).then().catch(); }} - > - {renderTreeNodes(treeData)} - + treeData={getTreeNodes(treeData)} + /> ) : (
    @@ -233,6 +213,7 @@ export class FileManager extends React.PureComponent { } private renderRemoteSelector(): JSX.Element { + const { onUploadRemoteFiles } = this.props; const { files } = this.state; return ( @@ -243,12 +224,14 @@ export class FileManager extends React.PureComponent { rows={6} value={[...files.remote].join('\n')} onChange={(event: React.ChangeEvent): void => { + const urls = event.target.value.split('\n'); this.setState({ files: { ...files, - remote: event.target.value.split('\n'), + remote: urls, }, }); + onUploadRemoteFiles(urls.filter(Boolean)); }} /> @@ -274,14 +257,14 @@ export class FileManager extends React.PureComponent { setSearchPhrase={(_potentialCloudStorage: string) => { this.setState({ potentialCloudStorage: _potentialCloudStorage }); }} - onSelectFiles={this.onSelectCloudStorageFiles} + onSelectFiles={this.handleUploadCloudStorageFiles} /> ); } public render(): JSX.Element { - const { onChangeActiveKey } = this.props; + const { onChangeActiveKey, many } = this.props; const { active } = this.state; return ( @@ -300,7 +283,7 @@ export class FileManager extends React.PureComponent { {this.renderLocalSelector()} {this.renderShareSelector()} {this.renderRemoteSelector()} - {this.renderCloudStorageSelector()} + {!many && this.renderCloudStorageSelector()} ); diff --git a/cvat-ui/src/components/file-manager/local-files.tsx b/cvat-ui/src/components/file-manager/local-files.tsx new file mode 100644 index 000000000000..546ca7c3abaa --- /dev/null +++ b/cvat-ui/src/components/file-manager/local-files.tsx @@ -0,0 +1,50 @@ +// Copyright (C) 2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; + +import Text from 'antd/lib/typography/Text'; +import Upload, { RcFile } from 'antd/lib/upload'; +import { InboxOutlined } from '@ant-design/icons'; + +interface Props { + files: File[]; + many: boolean; + onUpload: (_: RcFile, uploadedFiles: RcFile[]) => boolean; +} + +export default function LocalFiles(props: Props): JSX.Element { + const { files, onUpload, many } = props; + const hintText = many ? 'You can upload one or more videos' : + 'You can upload an archive with images, a video, or multiple images'; + + return ( + <> + +

    + +

    +

    Click or drag files to this area

    +

    { hintText }

    +
    + {files.length >= 5 && ( + <> +
    + {`${files.length} files selected`} + + )} + + ); +} diff --git a/cvat-ui/src/components/header/header.tsx b/cvat-ui/src/components/header/header.tsx index 97bec1749e69..03e97b76045c 100644 --- a/cvat-ui/src/components/header/header.tsx +++ b/cvat-ui/src/components/header/header.tsx @@ -227,7 +227,7 @@ function HeaderContainer(props: Props): JSX.Element { const resetOrganization = (): void => { localStorage.removeItem('currentOrganization'); - if (/\d+$/.test(window.location.pathname)) { + if (/(webhooks)|(\d+)/.test(window.location.pathname)) { window.location.pathname = '/'; } else { window.location.reload(); @@ -237,7 +237,7 @@ function HeaderContainer(props: Props): JSX.Element { const setNewOrganization = (organization: any): void => { if (!currentOrganization || currentOrganization.slug !== organization.slug) { localStorage.setItem('currentOrganization', organization.slug); - if (/\d+$/.test(window.location.pathname)) { + if (/\d+/.test(window.location.pathname)) { // a resource is opened (task/job/etc.) window.location.pathname = '/'; } else { diff --git a/cvat-ui/src/components/import-backup/import-backup-modal.tsx b/cvat-ui/src/components/import-backup/import-backup-modal.tsx new file mode 100644 index 000000000000..976da466f746 --- /dev/null +++ b/cvat-ui/src/components/import-backup/import-backup-modal.tsx @@ -0,0 +1,172 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import Form, { RuleObject } from 'antd/lib/form'; +import Text from 'antd/lib/typography/Text'; +import Notification from 'antd/lib/notification'; +import message from 'antd/lib/message'; +import Upload, { RcFile } from 'antd/lib/upload'; +import { InboxOutlined } from '@ant-design/icons'; +import { CombinedState, StorageLocation } from 'reducers'; +import { importActions, importBackupAsync } from 'actions/import-actions'; +import SourceStorageField from 'components/storage/source-storage-field'; +import Input from 'antd/lib/input/Input'; + +import { Storage, StorageData } from 'cvat-core-wrapper'; + +type FormValues = { + fileName?: string | undefined; + sourceStorage: StorageData; +}; + +const initialValues: FormValues = { + fileName: undefined, + sourceStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, +}; + +function ImportBackupModal(): JSX.Element { + const [form] = Form.useForm(); + const [file, setFile] = useState(null); + const instanceType = useSelector((state: CombinedState) => state.import.instanceType); + const modalVisible = useSelector((state: CombinedState) => { + if (instanceType && ['project', 'task'].includes(instanceType)) { + return state.import[`${instanceType}s` as 'projects' | 'tasks'].backup.modalVisible; + } + return false; + }); + const dispatch = useDispatch(); + const [selectedSourceStorage, setSelectedSourceStorage] = useState({ + location: StorageLocation.LOCAL, + }); + + const uploadLocalFile = (): JSX.Element => ( + { + if (!['application/zip', 'application/x-zip-compressed'].includes(_file.type)) { + message.error('Only ZIP archive is supported'); + } else { + setFile(_file); + } + return false; + }} + onRemove={() => { + setFile(null); + }} + > +

    + +

    +

    Click or drag file to this area

    +
    + ); + + const validateFileName = (_: RuleObject, value: string): Promise => { + if (value) { + const extension = value.toLowerCase().split('.')[1]; + if (extension !== 'zip') { + return Promise.reject(new Error('Only ZIP archive is supported')); + } + } + + return Promise.resolve(); + }; + + const renderCustomName = (): JSX.Element => ( + File name} + name='fileName' + rules={[{ validator: validateFileName }]} + > + + + ); + + const closeModal = useCallback((): void => { + setSelectedSourceStorage({ + location: StorageLocation.LOCAL, + }); + setFile(null); + dispatch(importActions.closeImportBackupModal(instanceType as 'project' | 'task')); + form.resetFields(); + }, [form, instanceType]); + + const handleImport = useCallback( + (values: FormValues): void => { + if (file === null && !values.fileName) { + Notification.error({ + message: 'No backup file specified', + }); + return; + } + const sourceStorage = new Storage({ + location: values.sourceStorage.location, + cloudStorageId: values.sourceStorage?.cloudStorageId, + }); + + dispatch(importBackupAsync(instanceType, sourceStorage, file || (values.fileName) as string)); + + Notification.info({ + message: `The ${instanceType} creating from the backup has been started`, + className: 'cvat-notification-notice-import-backup-start', + }); + closeModal(); + }, + [instanceType, file], + ); + + return ( + <> + + Create + {instanceType} + {' '} + from backup + + )} + visible={modalVisible} + onCancel={closeModal} + onOk={() => form.submit()} + className='cvat-modal-import-backup' + > + + setSelectedSourceStorage(new Storage(value))} + onChangeLocationValue={(value: StorageLocation) => { + setSelectedSourceStorage({ + location: value, + }); + }} + + /> + {selectedSourceStorage?.location === StorageLocation.CLOUD_STORAGE && renderCustomName()} + {selectedSourceStorage?.location === StorageLocation.LOCAL && uploadLocalFile()} + + + + ); +} + +export default React.memo(ImportBackupModal); diff --git a/cvat-ui/src/components/import-dataset-modal/import-dataset-modal.tsx b/cvat-ui/src/components/import-dataset-modal/import-dataset-modal.tsx deleted file mode 100644 index f395390b1006..000000000000 --- a/cvat-ui/src/components/import-dataset-modal/import-dataset-modal.tsx +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (C) 2021-2022 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import './styles.scss'; -import React, { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import Modal from 'antd/lib/modal'; -import Form from 'antd/lib/form'; -import Text from 'antd/lib/typography/Text'; -import Select from 'antd/lib/select'; -import Notification from 'antd/lib/notification'; -import message from 'antd/lib/message'; -import Upload, { RcFile } from 'antd/lib/upload'; - -import { - UploadOutlined, InboxOutlined, LoadingOutlined, QuestionCircleOutlined, -} from '@ant-design/icons'; - -import CVATTooltip from 'components/common/cvat-tooltip'; -import { CombinedState } from 'reducers'; -import { importActions, importDatasetAsync } from 'actions/import-actions'; - -import ImportDatasetStatusModal from './import-dataset-status-modal'; - -type FormValues = { - selectedFormat: string | undefined; -}; - -function ImportDatasetModal(): JSX.Element { - const [form] = Form.useForm(); - const [file, setFile] = useState(null); - const modalVisible = useSelector((state: CombinedState) => state.import.modalVisible); - const instance = useSelector((state: CombinedState) => state.import.instance); - const currentImportId = useSelector((state: CombinedState) => state.import.importingId); - const importers = useSelector((state: CombinedState) => state.formats.annotationFormats.loaders); - const dispatch = useDispatch(); - - const closeModal = useCallback((): void => { - form.resetFields(); - setFile(null); - dispatch(importActions.closeImportModal()); - }, [form]); - - const handleImport = useCallback( - (values: FormValues): void => { - if (file === null) { - Notification.error({ - message: 'No dataset file selected', - }); - return; - } - dispatch(importDatasetAsync(instance, values.selectedFormat as string, file)); - closeModal(); - Notification.info({ - message: 'Dataset import started', - description: `Dataset import was started for project #${instance?.id}. `, - className: 'cvat-notification-notice-import-dataset-start', - }); - }, - [instance?.id, file], - ); - - return ( - <> - - Import dataset to project - - - - - )} - visible={modalVisible} - onCancel={closeModal} - onOk={() => form.submit()} - className='cvat-modal-import-dataset' - > -
    - - - - { - if (!['application/zip', 'application/x-zip-compressed'].includes(_file.type)) { - message.error('Only ZIP archive is supported'); - } else { - setFile(_file); - } - return false; - }} - onRemove={() => { - setFile(null); - }} - > -

    - -

    -

    Click or drag file to this area

    -
    -
    -
    - - - ); -} - -export default React.memo(ImportDatasetModal); diff --git a/cvat-ui/src/components/import-dataset-modal/import-dataset-status-modal.tsx b/cvat-ui/src/components/import-dataset-modal/import-dataset-status-modal.tsx deleted file mode 100644 index 6c4645d277a9..000000000000 --- a/cvat-ui/src/components/import-dataset-modal/import-dataset-status-modal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (C) 2021-2022 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import './styles.scss'; -import React from 'react'; -import { useSelector } from 'react-redux'; -import Modal from 'antd/lib/modal'; -import Alert from 'antd/lib/alert'; -import Progress from 'antd/lib/progress'; - -import { CombinedState } from 'reducers'; - -function ImportDatasetStatusModal(): JSX.Element { - const currentImportId = useSelector((state: CombinedState) => state.import.importingId); - const progress = useSelector((state: CombinedState) => state.import.progress); - const status = useSelector((state: CombinedState) => state.import.status); - - return ( - - - - - ); -} - -export default React.memo(ImportDatasetStatusModal); diff --git a/cvat-ui/src/components/import-dataset/import-dataset-modal.tsx b/cvat-ui/src/components/import-dataset/import-dataset-modal.tsx new file mode 100644 index 000000000000..fc6c426ed6c3 --- /dev/null +++ b/cvat-ui/src/components/import-dataset/import-dataset-modal.tsx @@ -0,0 +1,463 @@ +// Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useCallback, useEffect, useState } from 'react'; +import { connect, useDispatch } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import Form, { RuleObject } from 'antd/lib/form'; +import Text from 'antd/lib/typography/Text'; +import Select from 'antd/lib/select'; +import Notification from 'antd/lib/notification'; +import message from 'antd/lib/message'; +import Upload, { RcFile } from 'antd/lib/upload'; +import Input from 'antd/lib/input/Input'; +import { + UploadOutlined, InboxOutlined, LoadingOutlined, QuestionCircleOutlined, +} from '@ant-design/icons'; +import CVATTooltip from 'components/common/cvat-tooltip'; +import { CombinedState, StorageLocation } from 'reducers'; +import { importActions, importDatasetAsync } from 'actions/import-actions'; +import Space from 'antd/lib/space'; +import Switch from 'antd/lib/switch'; +import { getCore, Storage, StorageData } from 'cvat-core-wrapper'; +import StorageField from 'components/storage/storage-field'; +import ImportDatasetStatusModal from './import-dataset-status-modal'; + +const { confirm } = Modal; + +const core = getCore(); + +type FormValues = { + selectedFormat: string | undefined; + fileName?: string | undefined; + sourceStorage: StorageData; + useDefaultSettings: boolean; +}; + +const initialValues: FormValues = { + selectedFormat: undefined, + fileName: undefined, + sourceStorage: { + location: StorageLocation.LOCAL, + cloudStorageId: undefined, + }, + useDefaultSettings: true, +}; + +interface UploadParams { + resource: 'annotation' | 'dataset'; + useDefaultSettings: boolean; + sourceStorage: Storage; + selectedFormat: string | null; + file: File | null; + fileName: string | null; +} + +function ImportDatasetModal(props: StateToProps): JSX.Element { + const { + importers, + instanceT, + instance, + current, + } = props; + const [form] = Form.useForm(); + const dispatch = useDispatch(); + // TODO useState -> useReducer + const [instanceType, setInstanceType] = useState(''); + const [file, setFile] = useState(null); + const [selectedLoader, setSelectedLoader] = useState(null); + const [useDefaultSettings, setUseDefaultSettings] = useState(true); + const [defaultStorageLocation, setDefaultStorageLocation] = useState(StorageLocation.LOCAL); + const [defaultStorageCloudId, setDefaultStorageCloudId] = useState(undefined); + const [helpMessage, setHelpMessage] = useState(''); + const [selectedSourceStorageLocation, setSelectedSourceStorageLocation] = useState(StorageLocation.LOCAL); + const [uploadParams, setUploadParams] = useState({ + useDefaultSettings: true, + } as UploadParams); + const [resource, setResource] = useState(''); + + useEffect(() => { + if (instanceT === 'project') { + setResource('dataset'); + } else if (instanceT === 'task' || instanceT === 'job') { + setResource('annotation'); + } + }, [instanceT]); + + const isDataset = useCallback((): boolean => resource === 'dataset', [resource]); + + const isAnnotation = useCallback((): boolean => resource === 'annotation', [resource]); + + useEffect(() => { + setUploadParams({ + ...uploadParams, + resource, + sourceStorage: { + location: defaultStorageLocation, + cloudStorageId: defaultStorageCloudId, + } as Storage, + } as UploadParams); + }, [resource, defaultStorageLocation, defaultStorageCloudId]); + + useEffect(() => { + if (instance) { + if (instance instanceof core.classes.Project || instance instanceof core.classes.Task) { + setDefaultStorageLocation(instance.sourceStorage?.location || StorageLocation.LOCAL); + setDefaultStorageCloudId(instance.sourceStorage?.cloudStorageId || null); + if (instance instanceof core.classes.Project) { + setInstanceType(`project #${instance.id}`); + } else { + setInstanceType(`task #${instance.id}`); + } + } else if (instance instanceof core.classes.Job) { + core.tasks.get({ id: instance.taskId }) + .then((response: any) => { + if (response.length) { + const [taskInstance] = response; + setDefaultStorageLocation(taskInstance.sourceStorage?.location || StorageLocation.LOCAL); + setDefaultStorageCloudId(taskInstance.sourceStorage?.cloudStorageId || null); + } + }) + .catch((error: Error) => { + if ((error as any).code !== 403) { + Notification.error({ + message: `Could not get task instance ${instance.taskId}`, + description: error.toString(), + }); + } + }); + setInstanceType(`job #${instance.id}`); + } + } + }, [instance, resource]); + + useEffect(() => { + setHelpMessage( + // eslint-disable-next-line prefer-template + `Import from ${(defaultStorageLocation) ? defaultStorageLocation.split('_')[0] : 'local'} ` + + `storage ${(defaultStorageCloudId) ? `№${defaultStorageCloudId}` : ''}`, + ); + }, [defaultStorageLocation, defaultStorageCloudId]); + + const uploadLocalFile = (): JSX.Element => ( + { + if (!selectedLoader) { + message.warn('Please select a format first', 3); + } else if (isDataset() && !['application/zip', 'application/x-zip-compressed'].includes(_file.type)) { + message.error('Only ZIP archive is supported for import a dataset'); + } else if (isAnnotation() && + !selectedLoader.format.toLowerCase().split(', ').includes(_file.name.split('.')[_file.name.split('.').length - 1])) { + message.error( + `For ${selectedLoader.name} format only files with ` + + `${selectedLoader.format.toLowerCase()} extension can be used`, + ); + } else { + setFile(_file); + setUploadParams({ + ...uploadParams, + file: _file, + } as UploadParams); + } + return false; + }} + onRemove={() => { + setFile(null); + }} + > +

    + +

    +

    Click or drag file to this area

    +
    + ); + + const validateFileName = (_: RuleObject, value: string): Promise => { + if (!selectedLoader) { + message.warn('Please select a format first', 3); + return Promise.reject(); + } + if (value) { + const extension = value.toLowerCase().split('.')[value.split('.').length - 1]; + if (isAnnotation()) { + const allowedExtensions = selectedLoader.format.toLowerCase().split(', '); + if (!allowedExtensions.includes(extension)) { + return Promise.reject(new Error( + `For ${selectedLoader.name} format only files with ` + + `${selectedLoader.format.toLowerCase()} extension can be used`, + )); + } + } + if (isDataset()) { + if (extension !== 'zip') { + return Promise.reject(new Error('Only ZIP archive is supported for import a dataset')); + } + } + } + + return Promise.resolve(); + }; + + const renderCustomName = (): JSX.Element => ( + File name} + name='fileName' + hasFeedback + dependencies={['selectedFormat']} + rules={[{ validator: validateFileName }]} + required + > + ) => { + if (e.target.value) { + setUploadParams({ + ...uploadParams, + fileName: e.target.value, + } as UploadParams); + } + }} + /> + + ); + + const closeModal = useCallback((): void => { + setUseDefaultSettings(true); + setSelectedSourceStorageLocation(StorageLocation.LOCAL); + form.resetFields(); + setFile(null); + dispatch(importActions.closeImportDatasetModal(instance)); + }, [form, instance]); + + const onUpload = (): void => { + if (uploadParams && uploadParams.resource) { + dispatch(importDatasetAsync( + instance, uploadParams.selectedFormat as string, + uploadParams.useDefaultSettings, uploadParams.sourceStorage, + uploadParams.file || uploadParams.fileName as string, + )); + const resToPrint = uploadParams.resource.charAt(0).toUpperCase() + uploadParams.resource.slice(1); + Notification.info({ + message: `${resToPrint} import started`, + description: `${resToPrint} import was started for ${instanceType}. `, + className: `cvat-notification-notice-import-${uploadParams.resource}-start`, + }); + } + }; + + const confirmUpload = (): void => { + confirm({ + title: 'Current annotation will be lost', + content: `You are going to upload new annotations to ${instanceType}. Continue?`, + className: `cvat-modal-content-load-${instanceType.split(' ')[0]}-annotation`, + onOk: () => { + onUpload(); + }, + okButtonProps: { + type: 'primary', + danger: true, + }, + okText: 'Update', + }); + }; + + const handleImport = useCallback( + (values: FormValues): void => { + if (uploadParams.file === null && !values.fileName) { + Notification.error({ + message: `No ${uploadParams.resource} file specified`, + }); + return; + } + + if (isAnnotation()) { + confirmUpload(); + } else { + onUpload(); + } + closeModal(); + }, + [instance, uploadParams], + ); + + return ( + <> + + + {`Import ${resource} to ${instanceType}`} + + { + instance instanceof core.classes.Project && ( + + + + ) + } + + )} + visible={!!instance} + onCancel={closeModal} + onOk={() => form.submit()} + className='cvat-modal-import-dataset' + > +
    + + + + + + { + setUseDefaultSettings(value); + setUploadParams({ + ...uploadParams, + useDefaultSettings: value, + } as UploadParams); + }} + /> + + Use default settings + + + + + + { + useDefaultSettings && ( + defaultStorageLocation === StorageLocation.LOCAL || + defaultStorageLocation === null + ) && uploadLocalFile() + } + { + useDefaultSettings && + defaultStorageLocation === StorageLocation.CLOUD_STORAGE && + renderCustomName() + } + {!useDefaultSettings && ( + { + setUploadParams({ + ...uploadParams, + sourceStorage: new Storage({ + location: value?.location || defaultStorageLocation, + cloudStorageId: (value.location) ? value.cloudStorageId : defaultStorageCloudId, + }), + } as UploadParams); + }} + locationValue={selectedSourceStorageLocation} + onChangeLocationValue={(value: StorageLocation) => setSelectedSourceStorageLocation(value)} + /> + )} + { + !useDefaultSettings && + selectedSourceStorageLocation === StorageLocation.CLOUD_STORAGE && + renderCustomName() + } + { + !useDefaultSettings && + selectedSourceStorageLocation === StorageLocation.LOCAL && + uploadLocalFile() + } + +
    + + + ); +} + +interface StateToProps { + importers: any; + instanceT: 'project' | 'task' | 'job' | null; + instance: any; + current: any; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { instanceType } = state.import; + + return { + importers: state.formats.annotationFormats.loaders, + instanceT: instanceType, + instance: !instanceType ? null : ( + state.import[`${instanceType}s` as 'projects' | 'tasks' | 'jobs'] + ).dataset.modalInstance, + current: !instanceType ? null : ( + state.import[`${instanceType}s` as 'projects' | 'tasks' | 'jobs'] + ).dataset.current, + }; +} + +export default connect(mapStateToProps)(ImportDatasetModal); diff --git a/cvat-ui/src/components/import-dataset/import-dataset-status-modal.tsx b/cvat-ui/src/components/import-dataset/import-dataset-status-modal.tsx new file mode 100644 index 000000000000..593b532e0309 --- /dev/null +++ b/cvat-ui/src/components/import-dataset/import-dataset-status-modal.tsx @@ -0,0 +1,58 @@ +// Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import Alert from 'antd/lib/alert'; +import Progress from 'antd/lib/progress'; + +import { CombinedState } from 'reducers'; + +function ImportDatasetStatusModal(): JSX.Element { + const current = useSelector((state: CombinedState) => state.import.projects.dataset.current); + const [importingId, setImportingId] = useState(null); + + useEffect(() => { + const [id] = Object.keys(current); + setImportingId(parseInt(id, 10)); + }, [current]); + + const importing = useSelector((state: CombinedState) => { + if (!importingId) { + return false; + } + return !!state.import.projects.dataset.current[importingId]; + }); + const progress = useSelector((state: CombinedState) => { + if (!importingId) { + return 0; + } + return state.import.projects.dataset.current[importingId]?.progress; + }); + const status = useSelector((state: CombinedState) => { + if (!importingId) { + return ''; + } + return state.import.projects.dataset.current[importingId]?.status; + }); + + return ( + + + + + ); +} + +export default React.memo(ImportDatasetStatusModal); diff --git a/cvat-ui/src/components/import-dataset-modal/styles.scss b/cvat-ui/src/components/import-dataset/styles.scss similarity index 90% rename from cvat-ui/src/components/import-dataset-modal/styles.scss rename to cvat-ui/src/components/import-dataset/styles.scss index ef72e86c2801..14c9c3c0dbf4 100644 --- a/cvat-ui/src/components/import-dataset-modal/styles.scss +++ b/cvat-ui/src/components/import-dataset/styles.scss @@ -17,6 +17,10 @@ color: $text-color-secondary; } +.cvat-modal-import-switch-use-default-storage { + display: table-cell; +} + .cvat-modal-import-dataset-status .ant-modal-body { display: flex; align-items: center; diff --git a/cvat-ui/src/components/jobs-page/job-card.tsx b/cvat-ui/src/components/jobs-page/job-card.tsx index 836e629b94df..6754fa71c1d3 100644 --- a/cvat-ui/src/components/jobs-page/job-card.tsx +++ b/cvat-ui/src/components/jobs-page/job-card.tsx @@ -1,8 +1,10 @@ // Copyright (C) 2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router'; import Card from 'antd/lib/card'; import Empty from 'antd/lib/empty'; @@ -12,8 +14,8 @@ import Dropdown from 'antd/lib/dropdown'; import Menu from 'antd/lib/menu'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; - import { useCardHeightHOC } from 'utils/hooks'; +import { exportActions } from 'actions/export-actions'; const useCardHeight = useCardHeightHOC({ containerClassName: 'cvat-jobs-page', @@ -28,6 +30,7 @@ interface Props { } function JobCardComponent(props: Props): JSX.Element { + const dispatch = useDispatch(); const { job, preview } = props; const [expanded, setExpanded] = useState(false); const history = useHistory(); @@ -97,6 +100,7 @@ function JobCardComponent(props: Props): JSX.Element { Go to the task Go to the project Go to the bug tracker + dispatch(exportActions.openExportDatasetModal(job))}>Export job
    )} > diff --git a/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx b/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx index a6be74669b86..cc4953a5f33f 100644 --- a/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx +++ b/cvat-ui/src/components/labels-editor/constructor-viewer-item.tsx @@ -22,11 +22,26 @@ export default function ConstructorViewerItem(props: ConstructorViewerItemProps) color, label, onUpdate, onDelete, } = props; + const backgroundColor = color || consts.NEW_LABEL_COLOR; + let textColor = '#ffffff'; + try { + // convert color to grayscale and from the result get better text color + // (for darken background -> lighter text, etc.) + const [r, g, b] = [backgroundColor.slice(1, 3), backgroundColor.slice(3, 5), backgroundColor.slice(5, 7)]; + const grayscale = (parseInt(r, 16) + parseInt(g, 16) + parseInt(b, 16)) / 3; + if (grayscale - 128 >= 0) { + textColor = '#000000'; + } + } catch (_: any) { + // nothing to do + } + return ( -
    - {label.name} +
    + {label.name} onUpdate(label)} @@ -37,6 +52,7 @@ export default function ConstructorViewerItem(props: ConstructorViewerItemProps) onDelete(label)} diff --git a/cvat-ui/src/components/labels-editor/styles.scss b/cvat-ui/src/components/labels-editor/styles.scss index 902af2430dc6..698928aa4a5b 100644 --- a/cvat-ui/src/components/labels-editor/styles.scss +++ b/cvat-ui/src/components/labels-editor/styles.scss @@ -45,15 +45,14 @@ textarea.ant-input.cvat-raw-labels-viewer { margin: 2px; margin-left: $grid-unit-size * 2; user-select: none; - border: 1px solid $transparent-color; + border: 1px solid $border-color-2; opacity: 0.6; > span { margin-left: 5px; - color: white; > span[role='img']:hover { - filter: invert(1); + filter: invert(0.2); } } diff --git a/cvat-ui/src/components/login-page/login-form.tsx b/cvat-ui/src/components/login-page/login-form.tsx index d57fe9c21d02..fb7e622ea28c 100644 --- a/cvat-ui/src/components/login-page/login-form.tsx +++ b/cvat-ui/src/components/login-page/login-form.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,7 +10,7 @@ import Input from 'antd/lib/input'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; export interface LoginData { - username: string; + credential: string; password: string; } @@ -24,18 +25,18 @@ function LoginFormComponent(props: Props): JSX.Element {
    } - placeholder='Username' + placeholder='Email or Username' /> diff --git a/cvat-ui/src/components/login-page/login-page.tsx b/cvat-ui/src/components/login-page/login-page.tsx index 63b63286aee1..9572a1ac476e 100644 --- a/cvat-ui/src/components/login-page/login-page.tsx +++ b/cvat-ui/src/components/login-page/login-page.tsx @@ -16,7 +16,7 @@ import LoginForm, { LoginData } from './login-form'; interface LoginPageComponentProps { fetching: boolean; renderResetPassword: boolean; - onLogin: (username: string, password: string) => void; + onLogin: (credential: string, password: string) => void; } function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps): JSX.Element { @@ -40,7 +40,7 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps { - onLogin(loginData.username, loginData.password); + onLogin(loginData.credential, loginData.password); }} /> diff --git a/cvat-ui/src/components/organization-page/styles.scss b/cvat-ui/src/components/organization-page/styles.scss index bb0f6a053e9d..cb96c3dd1063 100644 --- a/cvat-ui/src/components/organization-page/styles.scss +++ b/cvat-ui/src/components/organization-page/styles.scss @@ -1,4 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -148,3 +149,7 @@ .cvat-organization-invitation-field { align-items: baseline; } + +.cvat-organization-page-actions-button { + padding-right: $grid-unit-size * 0.5; +} diff --git a/cvat-ui/src/components/organization-page/top-bar.tsx b/cvat-ui/src/components/organization-page/top-bar.tsx index f2c84c8c98b9..b92dd8cab216 100644 --- a/cvat-ui/src/components/organization-page/top-bar.tsx +++ b/cvat-ui/src/components/organization-page/top-bar.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -13,11 +14,14 @@ import Space from 'antd/lib/space'; import Input from 'antd/lib/input'; import Form from 'antd/lib/form'; import Select from 'antd/lib/select'; +import Dropdown from 'antd/lib/dropdown'; +import Menu from 'antd/lib/menu'; import { useForm } from 'antd/lib/form/Form'; import { Store } from 'antd/lib/form/interface'; + import { EditTwoTone, EnvironmentOutlined, - MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined, + MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined, MoreOutlined, } from '@ant-design/icons'; import { @@ -26,6 +30,7 @@ import { removeOrganizationAsync, updateOrganizationAsync, } from 'actions/organization-actions'; +import { useHistory } from 'react-router-dom'; export interface Props { organizationInstance: any; @@ -33,6 +38,11 @@ export interface Props { fetchMembers: () => void; } +export enum MenuActions { + SET_WEBHOOKS = 'SET_WEBHOOKS', + REMOVE_ORGANIZATION = 'REMOVE_ORGANIZATION', +} + function OrganizationTopBar(props: Props): JSX.Element { const { organizationInstance, userInstance, fetchMembers } = props; const { @@ -62,14 +72,86 @@ function OrganizationTopBar(props: Props): JSX.Element { let organizationName = name; let organizationDescription = description; let organizationContacts = contact; + const history = useHistory(); + return ( <>
    - - {`Organization: ${slug} `} - + + + + {`Organization: ${slug} `} + + + + ( + + + { + e.preventDefault(); + history.push({ + pathname: '/organization/webhooks', + }); + return false; + }} + > + Setup webhooks + + + {owner && userID === owner.id ? ( + { + const modal = Modal.confirm({ + onOk: () => { + dispatch(removeOrganizationAsync(organizationInstance)); + }, + content: ( +
    + + To remove the organization, + enter its short name below + + ) => { + modal.update({ + okButtonProps: { + disabled: + event.target.value !== organizationInstance.slug, + danger: true, + }, + }); + }} + /> +
    + ), + okButtonProps: { + disabled: true, + danger: true, + }, + okText: 'Remove', + }); + }} + > + Remove organization +
    + ) : null} +
    + )} + > + + + + + + { @@ -213,44 +295,6 @@ function OrganizationTopBar(props: Props): JSX.Element { Leave organization ) : null} - {owner && userID === owner.id ? ( - - ) : null}
    + + + + + )} + > +
    -
    ) : content } - ); } diff --git a/cvat-ui/src/components/projects-page/top-bar.tsx b/cvat-ui/src/components/projects-page/top-bar.tsx index 76f1d2190f46..fc765c1894ac 100644 --- a/cvat-ui/src/components/projects-page/top-bar.tsx +++ b/cvat-ui/src/components/projects-page/top-bar.tsx @@ -1,16 +1,17 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router'; +import { useDispatch } from 'react-redux'; import { Row, Col } from 'antd/lib/grid'; import Button from 'antd/lib/button'; import Dropdown from 'antd/lib/dropdown'; import Input from 'antd/lib/input'; import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; -import Upload from 'antd/lib/upload'; - +import { importActions } from 'actions/import-actions'; import { usePrevious } from 'utils/hooks'; import { ProjectsQuery } from 'reducers'; import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; @@ -24,7 +25,6 @@ const FilteringComponent = ResourceFilterHOC( ); interface Props { - onImportProject(file: File): void; onApplyFilter(filter: string | null): void; onApplySorting(sorting: string | null): void; onApplySearch(search: string | null): void; @@ -33,8 +33,9 @@ interface Props { } function TopBarComponent(props: Props): JSX.Element { + const dispatch = useDispatch(); const { - importing, query, onApplyFilter, onApplySorting, onApplySearch, onImportProject, + importing, query, onApplyFilter, onApplySorting, onApplySearch, } = props; const [visibility, setVisibility] = useState(defaultVisibility); const prevImporting = usePrevious(importing); @@ -101,26 +102,16 @@ function TopBarComponent(props: Props): JSX.Element { > Create a new project - { - onImportProject(file); - return false; - }} - className='cvat-import-project' + - + Create from backup + {importing && } + )} > diff --git a/cvat-ui/src/components/register-page/register-form.tsx b/cvat-ui/src/components/register-page/register-form.tsx index 192dc1cdccaf..b81e3f91ceda 100644 --- a/cvat-ui/src/components/register-page/register-form.tsx +++ b/cvat-ui/src/components/register-page/register-form.tsx @@ -1,8 +1,9 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -import React from 'react'; +import React, { useState } from 'react'; import { UserAddOutlined, MailOutlined, LockOutlined } from '@ant-design/icons'; import Form, { RuleRender, RuleObject } from 'antd/lib/form'; import Button from 'antd/lib/button'; @@ -98,8 +99,11 @@ const validateAgreement: ((userAgreements: UserAgreement[]) => RuleRender) = ( function RegisterFormComponent(props: Props): JSX.Element { const { fetching, userAgreements, onSubmit } = props; + const [form] = Form.useForm(); + const [usernameEdited, setUsernameEdited] = useState(false); return ( ) => { const agreements = Object.keys(values) .filter((key: string):boolean => key.startsWith('agreement:')); @@ -155,44 +159,50 @@ function RegisterFormComponent(props: Props): JSX.Element { } - placeholder='Username' + autoComplete='email' + prefix={} + placeholder='Email address' + onChange={(event) => { + const { value } = event.target; + if (!usernameEdited) { + const [username] = value.split('@'); + form.setFieldsValue({ username }); + } + }} /> - } - placeholder='Email address' + prefix={} + placeholder='Username' + onChange={() => setUsernameEdited(true)} /> - void; onBuilderVisibleChange(visible: boolean): void; onRecentVisibleChange(visible: boolean): void; onApplyFilter(filter: string | null): void; @@ -35,7 +36,7 @@ export default function ResourceFilterHOC( filtrationCfg: Partial, localStorageRecentKeyword: string, localStorageRecentCapacity: number, - predefinedFilterValues: Record, + predefinedFilterValues?: Record, ): React.FunctionComponent { const config: Config = { ...AntdConfig, ...filtrationCfg }; const defaultTree = QbUtils.checkTree( @@ -100,9 +101,10 @@ export default function ResourceFilterHOC( return filters[0]; } - function getPredefinedFilters(user: User): Record { - const result: Record = {}; - if (user) { + function getPredefinedFilters(user: User): Record | null { + let result: Record | null = null; + if (user && predefinedFilterValues) { + result = {}; for (const key of Object.keys(predefinedFilterValues)) { result[key] = predefinedFilterValues[key].replace('', `${user.username}`); } @@ -190,50 +192,54 @@ export default function ResourceFilterHOC( const predefinedFilters = getPredefinedFilters(user); return (
    - - {Object.keys(predefinedFilters).map((key: string): JSX.Element => ( - { - let updatedValue: string[] | null = appliedFilter.predefined || []; - if (event.target.checked) { - updatedValue.push(predefinedFilters[key]); - } else { - updatedValue = updatedValue - .filter((appliedValue: string) => ( - appliedValue !== predefinedFilters[key] - )); - } + { + predefinedFilters && onPredefinedVisibleChange ? ( + + {Object.keys(predefinedFilters).map((key: string): JSX.Element => ( + { + let updatedValue: string[] | null = appliedFilter.predefined || []; + if (event.target.checked) { + updatedValue.push(predefinedFilters[key]); + } else { + updatedValue = updatedValue + .filter((appliedValue: string) => ( + appliedValue !== predefinedFilters[key] + )); + } - if (!updatedValue.length) { - updatedValue = null; - } + if (!updatedValue.length) { + updatedValue = null; + } - setAppliedFilter({ - ...defaultAppliedFilter, - predefined: updatedValue, - }); - }} - key={key} - > - {key} - - )) } -
    - )} - > - - + setAppliedFilter({ + ...defaultAppliedFilter, + predefined: updatedValue, + }); + }} + key={key} + > + {key} + + )) } + + )} + > + + + ) : null + } void; + onSelectCloudStorage: (cloudStorageId: number | null) => void; +} + +async function searchCloudStorages(filter: Record): Promise { + try { + const data = await getCore().cloudStorages.get(filter); + return data; + } catch (error) { + notification.error({ + message: 'Could not fetch a list of cloud storages', + description: error.toString(), + }); + } + + return []; +} + +const searchCloudStoragesWrapper = debounce((phrase, setList) => { + const filter = { + filter: JSON.stringify({ + and: [{ + '==': [{ var: 'display_name' }, phrase], + }], + }), + }; + searchCloudStorages(filter).then((list) => { + setList(list); + }); +}, 500); + +function SelectCloudStorage(props: Props): JSX.Element { + const { + searchPhrase, cloudStorage, name, setSearchPhrase, onSelectCloudStorage, + } = props; + const [initialList, setInitialList] = useState([]); + const [list, setList] = useState([]); + + useEffect(() => { + searchCloudStorages({}).then((data) => { + setInitialList(data); + if (!list.length) { + setList(data); + } + }); + }, []); + + useEffect(() => { + if (!searchPhrase) { + setList(initialList); + } else { + searchCloudStoragesWrapper(searchPhrase, setList); + } + }, [searchPhrase, initialList]); + + const onBlur = (): void => { + if (!searchPhrase && cloudStorage) { + onSelectCloudStorage(null); + } else if (searchPhrase) { + const potentialStorages = list.filter((_cloudStorage) => _cloudStorage.displayName.includes(searchPhrase)); + if (potentialStorages.length === 1) { + const potentialStorage = potentialStorages[0]; + setSearchPhrase(potentialStorage.displayName); + // eslint-disable-next-line prefer-destructuring + potentialStorage.manifestPath = potentialStorage.manifests[0]; + onSelectCloudStorage(potentialStorage); + } + } + }; + + return ( + + { + setSearchPhrase(phrase); + }} + options={list.map((_cloudStorage) => ({ + value: _cloudStorage.id.toString(), + label: ( + + {_cloudStorage.providerType === ProviderType.AWS_S3_BUCKET && } + {_cloudStorage.providerType === ProviderType.AZURE_CONTAINER && } + { + _cloudStorage.providerType === ProviderType.GOOGLE_CLOUD_STORAGE && + + } + {_cloudStorage.displayName} + + ), + }))} + onSelect={(value: string) => { + const selectedCloudStorage = + list.filter((_cloudStorage: CloudStorage) => _cloudStorage.id === +value)[0] || null; + // eslint-disable-next-line prefer-destructuring + [selectedCloudStorage.manifestPath] = selectedCloudStorage.manifests; + onSelectCloudStorage(selectedCloudStorage); + setSearchPhrase(selectedCloudStorage?.displayName || ''); + }} + allowClear + className={`cvat-search${!name ? '-' : `-${name[0].replace('Storage', '-storage')}-`}cloud-storage-field`} + > + + + + ); +} + +export default React.memo(SelectCloudStorage); diff --git a/cvat-ui/src/components/setup-webhook-pages/create-webhook-page.tsx b/cvat-ui/src/components/setup-webhook-pages/create-webhook-page.tsx new file mode 100644 index 000000000000..3dd585f412a8 --- /dev/null +++ b/cvat-ui/src/components/setup-webhook-pages/create-webhook-page.tsx @@ -0,0 +1,48 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import Button from 'antd/lib/button'; +import { Row, Col } from 'antd/lib/grid'; +import { LeftOutlined } from '@ant-design/icons'; +import { useHistory, useLocation } from 'react-router'; +import SetupWebhookContent from './setup-webhook-content'; + +function CreateWebhookPage(): JSX.Element { + const history = useHistory(); + const location = useLocation(); + const params = new URLSearchParams(location.search); + let defaultProjectId : number | null = null; + if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) { + defaultProjectId = +(params.get('projectId') as string); + } + + return ( +
    + + + + + + + + + + +
    + ); +} + +export default React.memo(CreateWebhookPage); diff --git a/cvat-ui/src/components/setup-webhook-pages/setup-webhook-content.tsx b/cvat-ui/src/components/setup-webhook-pages/setup-webhook-content.tsx new file mode 100644 index 000000000000..c1d79e262fa4 --- /dev/null +++ b/cvat-ui/src/components/setup-webhook-pages/setup-webhook-content.tsx @@ -0,0 +1,312 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { Store } from 'antd/lib/form/interface'; +import { Row, Col } from 'antd/lib/grid'; +import Form from 'antd/lib/form'; +import Text from 'antd/lib/typography/Text'; +import Button from 'antd/lib/button'; +import Checkbox from 'antd/lib/checkbox/Checkbox'; +import Input from 'antd/lib/input'; +import Radio, { RadioChangeEvent } from 'antd/lib/radio'; +import Select from 'antd/lib/select'; +import notification from 'antd/lib/notification'; + +import { getCore, Webhook } from 'cvat-core-wrapper'; +import ProjectSearchField from 'components/create-task-page/project-search-field'; +import { useSelector, useDispatch } from 'react-redux'; +import { CombinedState } from 'reducers'; +import { createWebhookAsync, updateWebhookAsync } from 'actions/webhooks-actions'; + +export enum WebhookContentType { + APPLICATION_JSON = 'application/json', +} + +export enum WebhookSourceType { + ORGANIZATION = 'organization', + PROJECT = 'project', +} + +export enum EventsMethod { + SEND_EVERYTHING = 'SEND_EVERYTHING', + SELECT_INDIVIDUAL = 'SELECT_INDIVIDUAL', +} + +export interface SetupWebhookData { + description: string; + targetUrl: string; + contentType: WebhookContentType; + secret: string; + enableSSL: boolean; + active: boolean; + eventsMethod: EventsMethod; +} + +interface Props { + webhook?: any; + defaultProjectId: number | null; +} + +export function groupEvents(events: string[]): string[] { + return Array.from( + new Set(events.map((event: string) => event.split(':')[1])), + ); +} + +function collectEvents(method: EventsMethod, submittedGroups: Record, allEvents: string[]): string[] { + return method === EventsMethod.SEND_EVERYTHING ? allEvents : (() => { + const submittedEvents = Object.entries(submittedGroups).filter(([key, value]) => key.startsWith('event:') && value).map(([key]) => key) + .map((event: string) => event.split(':')[1]); + return allEvents.filter((event) => submittedEvents.includes(event.split(':')[1])); + })(); +} + +function SetupWebhookContent(props: Props): JSX.Element { + const dispatch = useDispatch(); + const { webhook, defaultProjectId } = props; + const [form] = Form.useForm(); + const [rerender, setRerender] = useState(false); + const [showDetailedEvents, setShowDetailedEvents] = useState(false); + const [webhookEvents, setWebhookEvents] = useState([]); + + const organization = useSelector((state: CombinedState) => state.organizations.current); + + const [projectId, setProjectId] = useState(defaultProjectId); + + useEffect(() => { + const core = getCore(); + if (webhook) { + core.classes.Webhook.availableEvents(webhook.type).then((events: string[]) => { + setWebhookEvents(events); + }); + } else { + core.classes.Webhook.availableEvents(projectId ? + WebhookSourceType.PROJECT : WebhookSourceType.ORGANIZATION).then((events: string[]) => { + setWebhookEvents(events); + }); + } + }, [projectId]); + + useEffect(() => { + if (webhook) { + const eventsMethod = groupEvents(webhookEvents).length === groupEvents(webhook.events).length ? + EventsMethod.SEND_EVERYTHING : EventsMethod.SELECT_INDIVIDUAL; + setShowDetailedEvents(eventsMethod === EventsMethod.SELECT_INDIVIDUAL); + const data: Record = { + description: webhook.description, + targetURL: webhook.targetURL, + contentType: webhook.contentType, + secret: webhook.secret, + enableSSL: webhook.enableSSL, + isActive: webhook.isActive, + events: webhook.events, + eventsMethod, + }; + + webhook.events.forEach((event: string) => { + data[`event:${event.split(':')[1]}`] = true; + }); + + form.setFieldsValue(data); + setRerender(!rerender); + } + }, [webhook, webhookEvents]); + + const handleSubmit = useCallback(async (): Promise => { + try { + const values: Store = await form.validateFields(); + let notificationConfig = { + message: 'Webhook has been successfully updated', + className: 'cvat-notification-update-webhook-success', + }; + if (webhook) { + webhook.description = values.description; + webhook.targetURL = values.targetURL; + webhook.secret = values.secret; + webhook.contentType = values.contentType; + webhook.isActive = values.isActive; + webhook.enableSSL = values.enableSSL; + webhook.events = collectEvents(values.eventsMethod, values, webhookEvents); + + await dispatch(updateWebhookAsync(webhook)); + } else { + const rawWebhookData = { + description: values.description, + target_url: values.targetURL, + content_type: values.contentType, + secret: values.secret, + enable_ssl: values.enableSSL, + is_active: values.isActive, + events: collectEvents(values.eventsMethod, values, webhookEvents), + organization_id: projectId ? undefined : organization.id, + project_id: projectId, + type: projectId ? WebhookSourceType.PROJECT : WebhookSourceType.ORGANIZATION, + }; + notificationConfig = { + message: 'Webhook has been successfully added', + className: 'cvat-notification-create-webhook-success', + }; + await dispatch(createWebhookAsync(rawWebhookData)); + } + form.resetFields(); + setShowDetailedEvents(false); + notification.info(notificationConfig); + return webhook; + } catch (error) { + return null; + } + }, [webhook, webhookEvents]); + + const onEventsMethodChange = useCallback((event: RadioChangeEvent): void => { + form.setFieldsValue({ eventsMethod: event.target.value }); + setShowDetailedEvents(event.target.value === EventsMethod.SELECT_INDIVIDUAL); + setRerender(!rerender); + }, [rerender]); + + return ( + + + Setup a webhook + + + + + + + + + + { + !webhook && ( + + + Project + + + setProjectId(_projectId)} + value={projectId} + /> + + + ) + } + + + + + + + + + + Enable SSL + + + + + Active + + + + + + Send + everything + + + Select individual events + + + + { + showDetailedEvents && ( + + {groupEvents(webhookEvents).map((event: string, idx: number) => ( + + + + {event} + + + + ))} + + + ) + } + + + + + + + + + + + + ); +} + +export default React.memo(SetupWebhookContent); diff --git a/cvat-ui/src/components/setup-webhook-pages/styles.scss b/cvat-ui/src/components/setup-webhook-pages/styles.scss new file mode 100644 index 000000000000..5b8fe7d0cc47 --- /dev/null +++ b/cvat-ui/src/components/setup-webhook-pages/styles.scss @@ -0,0 +1,22 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +@import '../../base.scss'; + +.cvat-setup-webhook-content { + margin-top: $grid-unit-size; + width: 100%; + height: auto; + border: 1px solid $border-color-1; + border-radius: 3px; + padding: $grid-unit-size * 3; + background: $background-color-1; + text-align: initial; +} + +.cvat-create-webhook-page { + width: 100%; + height: 100%; + padding-top: $grid-unit-size * 5; +} diff --git a/cvat-ui/src/components/setup-webhook-pages/update-webhook-page.tsx b/cvat-ui/src/components/setup-webhook-pages/update-webhook-page.tsx new file mode 100644 index 000000000000..e3cbae65cdda --- /dev/null +++ b/cvat-ui/src/components/setup-webhook-pages/update-webhook-page.tsx @@ -0,0 +1,52 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useEffect } from 'react'; +import Button from 'antd/lib/button'; +import { Row, Col } from 'antd/lib/grid'; +import { LeftOutlined } from '@ant-design/icons'; +import { useHistory, useParams } from 'react-router'; +import { CombinedState } from 'reducers'; +import { useDispatch, useSelector } from 'react-redux'; +import { getWebhooksAsync } from 'actions/webhooks-actions'; +import SetupWebhookContent from './setup-webhook-content'; + +interface ParamType { + id: string; +} + +function UpdateWebhookPage(): JSX.Element { + const id = +useParams().id; + const history = useHistory(); + const dispatch = useDispatch(); + const webhooks = useSelector((state: CombinedState) => state.webhooks.current); + const [webhook] = webhooks.filter((_webhook) => _webhook.id === id); + + useEffect(() => { + if (!webhook) { + dispatch(getWebhooksAsync({ id })); + } + }, []); + + return ( +
    + + + + + + + + + + +
    + ); +} + +export default React.memo(UpdateWebhookPage); diff --git a/cvat-ui/src/components/storage/source-storage-field.tsx b/cvat-ui/src/components/storage/source-storage-field.tsx new file mode 100644 index 000000000000..9fda4561e7d1 --- /dev/null +++ b/cvat-ui/src/components/storage/source-storage-field.tsx @@ -0,0 +1,52 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import { StorageData } from 'cvat-core-wrapper'; +import { StorageLocation } from 'reducers'; +import StorageWithSwitchField from './storage-with-switch-field'; + +export interface Props { + instanceId: number | null; + locationValue: StorageLocation; + switchDescription?: string; + switchHelpMessage?: string; + storageDescription?: string; + useDefaultStorage?: boolean | null; + onChangeLocationValue?: (value: StorageLocation) => void; + onChangeStorage?: (values: StorageData) => void; + onChangeUseDefaultStorage?: (value: boolean) => void; +} + +export default function SourceStorageField(props: Props): JSX.Element { + const { + instanceId, + switchDescription, + switchHelpMessage, + storageDescription, + useDefaultStorage, + locationValue, + onChangeUseDefaultStorage, + onChangeStorage, + onChangeLocationValue, + } = props; + + return ( + + ); +} diff --git a/cvat-ui/src/components/storage/storage-field.tsx b/cvat-ui/src/components/storage/storage-field.tsx new file mode 100644 index 000000000000..410c3218a84d --- /dev/null +++ b/cvat-ui/src/components/storage/storage-field.tsx @@ -0,0 +1,102 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useEffect, useState } from 'react'; +import Select from 'antd/lib/select'; +import Form from 'antd/lib/form'; +import { CloudStorage, StorageLocation } from 'reducers'; +import SelectCloudStorage from 'components/select-cloud-storage/select-cloud-storage'; + +import { StorageData } from 'cvat-core-wrapper'; + +const { Option } = Select; + +export interface Props { + locationName: string[]; + selectCloudStorageName: string[]; + locationValue: StorageLocation; + onChangeLocationValue?: (value: StorageLocation) => void; + onChangeStorage?: (value: StorageData) => void; +} + +export default function StorageField(props: Props): JSX.Element { + const { + locationName, + selectCloudStorageName, + locationValue, + onChangeStorage, + onChangeLocationValue, + } = props; + const [cloudStorage, setCloudStorage] = useState(null); + const [potentialCloudStorage, setPotentialCloudStorage] = useState(''); + const [storageType, setStorageType] = useState(''); + + useEffect(() => { + setStorageType(locationName[0].replace('Storage', '-storage')); + }, [locationName]); + + function renderCloudStorage(): JSX.Element { + return ( + { + setPotentialCloudStorage(cs); + }} + name={selectCloudStorageName} + onSelectCloudStorage={(_cloudStorage: CloudStorage | null) => setCloudStorage(_cloudStorage)} + /> + ); + } + + useEffect(() => { + if (locationValue === StorageLocation.LOCAL) { + setPotentialCloudStorage(''); + } + }, [locationValue]); + + useEffect(() => { + if (onChangeStorage) { + onChangeStorage({ + location: locationValue, + cloudStorageId: cloudStorage?.id ? parseInt(cloudStorage?.id, 10) : undefined, + }); + } + }, [cloudStorage, locationValue]); + + return ( + <> + + + + {locationValue === StorageLocation.CLOUD_STORAGE && renderCloudStorage()} + + ); +} diff --git a/cvat-ui/src/components/storage/storage-with-switch-field.tsx b/cvat-ui/src/components/storage/storage-with-switch-field.tsx new file mode 100644 index 000000000000..6ff6ddda5410 --- /dev/null +++ b/cvat-ui/src/components/storage/storage-with-switch-field.tsx @@ -0,0 +1,104 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import Form from 'antd/lib/form'; +import Text from 'antd/lib/typography/Text'; +import Space from 'antd/lib/space'; +import Switch from 'antd/lib/switch'; +import Tooltip from 'antd/lib/tooltip'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import CVATTooltip from 'components/common/cvat-tooltip'; +import { StorageData } from 'cvat-core-wrapper'; +import { StorageLocation } from 'reducers'; +import StorageField from './storage-field'; + +export interface Props { + instanceId: number | null; + storageName: string; + storageLabel: string; + switchName: string; + locationValue: StorageLocation; + switchDescription?: string; + switchHelpMessage?: string; + storageDescription?: string; + useDefaultStorage?: boolean | null; + onChangeLocationValue?: (value: StorageLocation) => void; + onChangeStorage?: (values: StorageData) => void; + onChangeUseDefaultStorage?: (value: boolean) => void; +} + +export default function StorageWithSwitchField(props: Props): JSX.Element { + const { + instanceId, + storageName, + storageLabel, + switchName, + switchDescription, + switchHelpMessage, + storageDescription, + useDefaultStorage, + locationValue, + onChangeUseDefaultStorage, + onChangeStorage, + onChangeLocationValue, + } = props; + + return ( + <> + { + !!instanceId && ( + + + { + if (onChangeUseDefaultStorage) { + onChangeUseDefaultStorage(value); + } + }} + /> + + {switchDescription} + {(switchHelpMessage) ? ( + + + + ) : null} + + ) + } + { + (!instanceId || !useDefaultStorage) && ( + + + {storageLabel} + + + + + + )} + > + + + ) + } + + ); +} diff --git a/cvat-ui/src/components/storage/styles.scss b/cvat-ui/src/components/storage/styles.scss new file mode 100644 index 000000000000..6e87c818d18e --- /dev/null +++ b/cvat-ui/src/components/storage/styles.scss @@ -0,0 +1,10 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +@import '../../base.scss'; + +.cvat-question-circle-filled-icon { + font-size: $grid-unit-size * 14; + opacity: 0.5; +} diff --git a/cvat-ui/src/components/storage/target-storage-field.tsx b/cvat-ui/src/components/storage/target-storage-field.tsx new file mode 100644 index 000000000000..b403cae45188 --- /dev/null +++ b/cvat-ui/src/components/storage/target-storage-field.tsx @@ -0,0 +1,52 @@ +// (Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import { StorageLocation } from 'reducers'; +import { StorageData } from 'cvat-core-wrapper'; +import StorageWithSwitchField from './storage-with-switch-field'; + +export interface Props { + instanceId: number | null; + locationValue: StorageLocation; + switchDescription?: string; + switchHelpMessage?: string; + storageDescription?: string; + useDefaultStorage?: boolean | null; + onChangeLocationValue?: (value: StorageLocation) => void; + onChangeStorage?: (values: StorageData) => void; + onChangeUseDefaultStorage?: (value: boolean) => void; +} + +export default function TargetStorageField(props: Props): JSX.Element { + const { + instanceId, + locationValue, + switchDescription, + switchHelpMessage, + storageDescription, + useDefaultStorage, + onChangeLocationValue, + onChangeUseDefaultStorage, + onChangeStorage, + } = props; + + return ( + + ); +} diff --git a/cvat-ui/src/components/tasks-page/styles.scss b/cvat-ui/src/components/tasks-page/styles.scss index 2f35a26c6b81..454768f6d20f 100644 --- a/cvat-ui/src/components/tasks-page/styles.scss +++ b/cvat-ui/src/components/tasks-page/styles.scss @@ -5,31 +5,6 @@ @import '../../base.scss'; @import '../../styles.scss'; -.cvat-tasks-page-control-buttons-wrapper { - display: flex; - flex-direction: column; - background: $background-color-1; - padding: $grid-unit-size; - border-radius: 4px; - box-shadow: $box-shadow-base; - - > * { - &:not(:first-child) { - margin-top: $grid-unit-size; - } - - width: 100%; - - .ant-upload { - width: 100%; - - button { - width: 100%; - } - } - } -} - .cvat-tasks-page { padding-top: $grid-unit-size * 2; padding-bottom: $grid-unit-size; diff --git a/cvat-ui/src/components/tasks-page/tasks-page.tsx b/cvat-ui/src/components/tasks-page/tasks-page.tsx index c4d0e4c3ccfc..c1b58f8cc144 100644 --- a/cvat-ui/src/components/tasks-page/tasks-page.tsx +++ b/cvat-ui/src/components/tasks-page/tasks-page.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -17,7 +18,7 @@ import { TasksQuery, Indexable } from 'reducers'; import FeedbackComponent from 'components/feedback/feedback'; import { updateHistoryFromQuery } from 'components/resource-sorting-filtering'; import TaskListContainer from 'containers/tasks-page/tasks-list'; -import { getTasksAsync, hideEmptyTasks, importTaskAsync } from 'actions/tasks-actions'; +import { getTasksAsync, hideEmptyTasks } from 'actions/tasks-actions'; import TopBar from './top-bar'; import EmptyListComponent from './empty-list'; @@ -139,7 +140,6 @@ function TasksPageComponent(props: Props): JSX.Element { ); }} query={updatedQuery} - onImportTask={(file: File) => dispatch(importTaskAsync(file))} importing={importing} /> { fetching ? ( diff --git a/cvat-ui/src/components/tasks-page/top-bar.tsx b/cvat-ui/src/components/tasks-page/top-bar.tsx index cdd9fcb4e55b..f80061d2fb29 100644 --- a/cvat-ui/src/components/tasks-page/top-bar.tsx +++ b/cvat-ui/src/components/tasks-page/top-bar.tsx @@ -1,19 +1,23 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router'; + import { Row, Col } from 'antd/lib/grid'; import Dropdown from 'antd/lib/dropdown'; import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import Button from 'antd/lib/button'; -import Upload from 'antd/lib/upload'; import Input from 'antd/lib/input'; - +import { importActions } from 'actions/import-actions'; import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering'; import { TasksQuery } from 'reducers'; import { usePrevious } from 'utils/hooks'; +import { MutliPlusIcon } from 'icons'; +import CvatDropdownMenuPaper from 'components/common/cvat-dropdown-menu-paper'; import { localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, } from './tasks-filter-configuration'; @@ -23,7 +27,6 @@ const FilteringComponent = ResourceFilterHOC( ); interface VisibleTopBarProps { - onImportTask(file: File): void; onApplyFilter(filter: string | null): void; onApplySorting(sorting: string | null): void; onApplySearch(search: string | null): void; @@ -32,8 +35,9 @@ interface VisibleTopBarProps { } export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element { + const dispatch = useDispatch(); const { - importing, query, onApplyFilter, onApplySorting, onApplySearch, onImportTask, + importing, query, onApplyFilter, onApplySorting, onApplySearch, } = props; const [visibility, setVisibility] = useState(defaultVisibility); const history = useHistory(); @@ -90,7 +94,7 @@ export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element + - { - onImportTask(file); - return false; - }} - className='cvat-import-task' + - - + Create multi tasks + + + )} > + + + + + ( + + + { + e.preventDefault(); + history.push(`/webhooks/update/${id}`); + return false; + }} + > + Edit + + + { + Modal.confirm({ + title: 'Are you sure you want to remove the hook?', + content: 'It will stop notificating the specified URL about listed events', + className: 'cvat-modal-confirm-remove-webhook', + onOk: () => { + dispatch(deleteWebhookAsync(webhookInstance)).then(() => { + setIsRemoved(true); + }); + }, + }); + }} + > + Delete + + + )} + > +
    + Actions + +
    +
    + +
    + + + ); +} + +export default React.memo(WebhookItem); diff --git a/cvat-ui/src/components/webhooks-page/webhooks-filter-configuration.ts b/cvat-ui/src/components/webhooks-page/webhooks-filter-configuration.ts new file mode 100644 index 000000000000..5593cb7bdc72 --- /dev/null +++ b/cvat-ui/src/components/webhooks-page/webhooks-filter-configuration.ts @@ -0,0 +1,55 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { Config } from 'react-awesome-query-builder'; + +export const config: Partial = { + fields: { + description: { + label: 'Description', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + target_url: { + label: 'Target URL', + type: 'text', + valueSources: ['value'], + operators: ['like'], + }, + owner: { + label: 'Owner', + type: 'text', + valueSources: ['value'], + operators: ['equal'], + }, + updated_date: { + label: 'Last updated', + type: 'datetime', + operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + }, + type: { + label: 'Type', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: [ + { value: 'organization', title: 'Organization' }, + { value: 'project', title: 'Project' }, + ], + }, + }, + id: { + label: 'ID', + type: 'number', + operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'], + fieldSettings: { min: 0 }, + valueSources: ['value'], + }, + }, +}; + +export const localStorageRecentCapacity = 10; +export const localStorageRecentKeyword = 'recentlyAppliedWebhooksFilters'; +export const predefinedFilterValues = {}; diff --git a/cvat-ui/src/components/webhooks-page/webhooks-list.tsx b/cvat-ui/src/components/webhooks-page/webhooks-list.tsx new file mode 100644 index 000000000000..ac9bead1abc6 --- /dev/null +++ b/cvat-ui/src/components/webhooks-page/webhooks-list.tsx @@ -0,0 +1,29 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Row, Col } from 'antd/lib/grid'; +import { useSelector } from 'react-redux'; +import { CombinedState } from 'reducers'; +import WebhookItem from './webhook-item'; + +function WebhooksList(): JSX.Element { + const webhooks = useSelector((state: CombinedState) => state.webhooks.current); + return ( + + + {webhooks.map( + (webhook: any): JSX.Element => ( + + ), + )} + + + ); +} + +export default React.memo(WebhooksList); diff --git a/cvat-ui/src/components/webhooks-page/webhooks-page.tsx b/cvat-ui/src/components/webhooks-page/webhooks-page.tsx new file mode 100644 index 000000000000..98129558ef6a --- /dev/null +++ b/cvat-ui/src/components/webhooks-page/webhooks-page.tsx @@ -0,0 +1,151 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + useHistory, useRouteMatch, +} from 'react-router'; +import Spin from 'antd/lib/spin'; +import { Row, Col } from 'antd/lib/grid'; +import Pagination from 'antd/lib/pagination'; +import Button from 'antd/lib/button'; + +import { CombinedState, Indexable } from 'reducers'; +import { updateHistoryFromQuery } from 'components/resource-sorting-filtering'; +import { getWebhooksAsync } from 'actions/webhooks-actions'; +import { LeftOutlined } from '@ant-design/icons'; +import WebhooksList from './webhooks-list'; +import TopBar from './top-bar'; +import EmptyWebhooksListComponent from './empty-list'; + +interface ProjectRouteMatch { + id?: string | undefined; +} + +const PAGE_SIZE = 10; + +function WebhooksPage(): JSX.Element | null { + const dispatch = useDispatch(); + const history = useHistory(); + const organization = useSelector((state: CombinedState) => state.organizations.current); + const fetching = useSelector((state: CombinedState) => state.webhooks.fetching); + const totalCount = useSelector((state: CombinedState) => state.webhooks.totalCount); + const query = useSelector((state: CombinedState) => state.webhooks.query); + + const projectsMatch = useRouteMatch({ path: '/projects/:id/webhooks' }); + + const [onCreateParams, setOnCreateParams] = useState(null); + const onCreateWebhook = useCallback(() => { + history.push(`/webhooks/create?${onCreateParams || ''}`); + }, [onCreateParams]); + + const goBackContent = ( + + ); + + const queryParams = new URLSearchParams(history.location.search); + const updatedQuery = { ...query }; + for (const key of Object.keys(updatedQuery)) { + (updatedQuery as Indexable)[key] = queryParams.get(key) || null; + if (key === 'page') { + updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1; + } + } + + useEffect(() => { + if (projectsMatch && projectsMatch.params.id) { + const { id } = projectsMatch.params; + setOnCreateParams(`projectId=${id}`); + dispatch(getWebhooksAsync({ ...updatedQuery, projectId: +id })); + } else if (organization) { + dispatch(getWebhooksAsync(updatedQuery)); + } else { + history.push('/'); + } + }, [organization]); + + useEffect(() => { + history.replace({ + search: updateHistoryFromQuery(query), + }); + }, [query]); + + const content = totalCount ? ( + <> + + + + { + dispatch(getWebhooksAsync({ + ...query, + page, + })); + }} + showSizeChanger={false} + total={totalCount} + current={query.page} + pageSize={PAGE_SIZE} + showQuickJumper + /> + + + + ) : ; + + return ( +
    + { + dispatch( + getWebhooksAsync({ + ...query, + search, + page: 1, + }), + ); + }} + onApplyFilter={(filter: string | null) => { + dispatch( + getWebhooksAsync({ + ...query, + filter, + page: 1, + }), + ); + }} + onApplySorting={(sorting: string | null) => { + dispatch( + getWebhooksAsync({ + ...query, + sort: sorting, + page: 1, + }), + ); + }} + /> + { fetching ? ( +
    + +
    + ) : content } +
    + ); +} + +export default React.memo(WebhooksPage); diff --git a/cvat-ui/src/containers/actions-menu/actions-menu.tsx b/cvat-ui/src/containers/actions-menu/actions-menu.tsx index c401f7fbc6ee..34bd5bffd89f 100644 --- a/cvat-ui/src/containers/actions-menu/actions-menu.tsx +++ b/cvat-ui/src/containers/actions-menu/actions-menu.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -6,18 +7,16 @@ import React from 'react'; import { connect } from 'react-redux'; // eslint-disable-next-line import/no-extraneous-dependencies import { MenuInfo } from 'rc-menu/lib/interface'; - import ActionsMenuComponent, { Actions } from 'components/actions-menu/actions-menu'; import { CombinedState } from 'reducers'; import { modelsActions } from 'actions/models-actions'; import { - loadAnnotationsAsync, deleteTaskAsync, - exportTaskAsync, switchMoveTaskModalVisible, } from 'actions/tasks-actions'; import { exportActions } from 'actions/export-actions'; +import { importActions } from 'actions/import-actions'; interface OwnProps { taskInstance: any; @@ -25,17 +24,15 @@ interface OwnProps { interface StateToProps { annotationFormats: any; - loadActivity: string | null; inferenceIsActive: boolean; - exportIsActive: boolean; + backupIsActive: boolean; } interface DispatchToProps { - loadAnnotations: (taskInstance: any, loader: any, file: File) => void; - showExportModal: (taskInstance: any) => void; - deleteTask: (taskInstance: any) => void; + showExportModal: (taskInstance: any, resource: 'dataset' | 'backup') => void; + showImportModal: (taskInstance: any) => void; openRunModelWindow: (taskInstance: any) => void; - exportTask: (taskInstance: any) => void; + deleteTask: (taskInstance: any) => void; openMoveTaskToProjectWindow: (taskInstance: any) => void; } @@ -46,26 +43,26 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { const { formats: { annotationFormats }, - tasks: { - activities: { loads, backups }, - }, } = state; return { - loadActivity: tid in loads ? loads[tid] : null, annotationFormats, inferenceIsActive: tid in state.models.inferences, - exportIsActive: tid in backups, + backupIsActive: state.export.tasks.backup.current[tid], }; } function mapDispatchToProps(dispatch: any): DispatchToProps { return { - loadAnnotations: (taskInstance: any, loader: any, file: File): void => { - dispatch(loadAnnotationsAsync(taskInstance, loader, file)); + showExportModal: (taskInstance: any, resource: 'dataset' | 'backup'): void => { + if (resource === 'dataset') { + dispatch(exportActions.openExportDatasetModal(taskInstance)); + } else { + dispatch(exportActions.openExportBackupModal(taskInstance)); + } }, - showExportModal: (taskInstance: any): void => { - dispatch(exportActions.openExportModal(taskInstance)); + showImportModal: (taskInstance: any): void => { + dispatch(importActions.openImportDatasetModal(taskInstance)); }, deleteTask: (taskInstance: any): void => { dispatch(deleteTaskAsync(taskInstance)); @@ -73,9 +70,6 @@ function mapDispatchToProps(dispatch: any): DispatchToProps { openRunModelWindow: (taskInstance: any): void => { dispatch(modelsActions.showRunModelDialog(taskInstance)); }, - exportTask: (taskInstance: any): void => { - dispatch(exportTaskAsync(taskInstance)); - }, openMoveTaskToProjectWindow: (taskId: number): void => { dispatch(switchMoveTaskModalVisible(true, taskId)); }, @@ -86,21 +80,19 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): const { taskInstance, annotationFormats: { loaders, dumpers }, - loadActivity, inferenceIsActive, - exportIsActive, - loadAnnotations, + backupIsActive, showExportModal, + showImportModal, deleteTask, openRunModelWindow, - exportTask, openMoveTaskToProjectWindow, } = props; - const onClickMenu = (params: MenuInfo): void => { + const onClickMenu = (params: MenuInfo): void | JSX.Element => { const [action] = params.keyPath; if (action === Actions.EXPORT_TASK_DATASET) { - showExportModal(taskInstance); + showExportModal(taskInstance, 'dataset'); } else if (action === Actions.DELETE_TASK) { deleteTask(taskInstance); } else if (action === Actions.OPEN_BUG_TRACKER) { @@ -108,17 +100,12 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): window.open(`${taskInstance.bugTracker}`, '_blank'); } else if (action === Actions.RUN_AUTO_ANNOTATION) { openRunModelWindow(taskInstance); - } else if (action === Actions.EXPORT_TASK) { - exportTask(taskInstance); + } else if (action === Actions.BACKUP_TASK) { + showExportModal(taskInstance, 'backup'); } else if (action === Actions.MOVE_TASK_TO_PROJECT) { openMoveTaskToProjectWindow(taskInstance.id); - } - }; - - const onUploadAnnotations = (format: string, file: File): void => { - const [loader] = loaders.filter((_loader: any): boolean => _loader.name === format); - if (loader && file) { - loadAnnotations(taskInstance, loader, file); + } else if (action === Actions.LOAD_TASK_ANNO) { + showImportModal(taskInstance); } }; @@ -129,12 +116,10 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps): bugTracker={taskInstance.bugTracker} loaders={loaders} dumpers={dumpers} - loadActivity={loadActivity} inferenceIsActive={inferenceIsActive} onClickMenu={onClickMenu} - onUploadAnnotations={onUploadAnnotations} taskDimension={taskInstance.dimension} - exportIsActive={exportIsActive} + backupIsActive={backupIsActive} /> ); } diff --git a/cvat-ui/src/containers/annotation-page/top-bar/annotation-menu.tsx b/cvat-ui/src/containers/annotation-page/top-bar/annotation-menu.tsx index f5d64f505516..0e9908c71a92 100644 --- a/cvat-ui/src/containers/annotation-page/top-bar/annotation-menu.tsx +++ b/cvat-ui/src/containers/annotation-page/top-bar/annotation-menu.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -12,26 +13,24 @@ import { CombinedState, JobStage } from 'reducers'; import AnnotationMenuComponent, { Actions } from 'components/annotation-page/top-bar/annotation-menu'; import { updateJobAsync } from 'actions/tasks-actions'; import { - uploadJobAnnotationsAsync, saveAnnotationsAsync, setForceExitAnnotationFlag as setForceExitAnnotationFlagAction, removeAnnotationsAsync as removeAnnotationsAsyncAction, } from 'actions/annotation-actions'; import { exportActions } from 'actions/export-actions'; +import { importActions } from 'actions/import-actions'; import { getCore } from 'cvat-core-wrapper'; const core = getCore(); interface StateToProps { - annotationFormats: any; jobInstance: any; stopFrame: number; - loadActivity: string | null; } interface DispatchToProps { - loadAnnotations(job: any, loader: any, file: File): void; - showExportModal(jobInstance: any): void; + showExportModal: (jobInstance: any) => void; + showImportModal: (jobInstance: any) => void; removeAnnotations(startnumber: number, endnumber: number, delTrackKeyframesOnly: boolean): void; setForceExitAnnotationFlag(forceExit: boolean): void; saveAnnotations(jobInstance: any, afterSave?: () => void): void; @@ -41,36 +40,26 @@ interface DispatchToProps { function mapStateToProps(state: CombinedState): StateToProps { const { annotation: { - activities: { loads: jobLoads }, job: { instance: jobInstance, instance: { stopFrame }, }, }, - formats: { annotationFormats }, - tasks: { - activities: { loads }, - }, } = state; - const taskID = jobInstance.taskId; - const jobID = jobInstance.id; - return { - loadActivity: taskID in loads || jobID in jobLoads ? loads[taskID] || jobLoads[jobID] : null, jobInstance, stopFrame, - annotationFormats, }; } function mapDispatchToProps(dispatch: any): DispatchToProps { return { - loadAnnotations(job: any, loader: any, file: File): void { - dispatch(uploadJobAnnotationsAsync(job, loader, file)); - }, showExportModal(jobInstance: any): void { - dispatch(exportActions.openExportModal(jobInstance)); + dispatch(exportActions.openExportDatasetModal(jobInstance)); + }, + showImportModal(jobInstance: any): void { + dispatch(importActions.openImportDatasetModal(jobInstance)); }, removeAnnotations(startnumber: number, endnumber: number, delTrackKeyframesOnly:boolean) { dispatch(removeAnnotationsAsyncAction(startnumber, endnumber, delTrackKeyframesOnly)); @@ -93,27 +82,18 @@ function AnnotationMenuContainer(props: Props): JSX.Element { const { jobInstance, stopFrame, - annotationFormats: { loaders, dumpers }, history, - loadActivity, - loadAnnotations, showExportModal, + showImportModal, removeAnnotations, setForceExitAnnotationFlag, saveAnnotations, updateJob, } = props; - const onUploadAnnotations = (format: string, file: File): void => { - const [loader] = loaders.filter((_loader: any): boolean => _loader.name === format); - if (loader && file) { - loadAnnotations(jobInstance, loader, file); - } - }; - const onClickMenu = (params: MenuInfo): void => { const [action] = params.keyPath; - if (action === Actions.EXPORT_TASK_DATASET) { + if (action === Actions.EXPORT_JOB_DATASET) { showExportModal(jobInstance); } else if (action === Actions.RENEW_JOB) { jobInstance.state = core.enums.JobState.NEW; @@ -131,16 +111,14 @@ function AnnotationMenuContainer(props: Props): JSX.Element { [, jobInstance.state] = action.split(':'); updateJob(jobInstance); window.location.reload(); + } else if (action === Actions.LOAD_JOB_ANNO) { + showImportModal(jobInstance); } }; return ( void; + onCreate: (data: CreateTaskData, onProgress?: (status: string) => void) => Promise; } function mapDispatchToProps(dispatch: any): DispatchToProps { return { - onCreate: (data: CreateTaskData): Promise => dispatch(createTaskAsync(data)), + onCreate: (data, onProgress) => dispatch(createTaskAsync(data, onProgress)), }; } function mapStateToProps(state: CombinedState): StateToProps { - const { creates } = state.tasks.activities; return { - ...creates, installedGit: state.plugins.list.GIT_INTEGRATION, dumpers: state.formats.annotationFormats.dumpers, }; diff --git a/cvat-ui/src/containers/file-manager/file-manager.tsx b/cvat-ui/src/containers/file-manager/file-manager.tsx index 54800596f358..dfddf22cab38 100644 --- a/cvat-ui/src/containers/file-manager/file-manager.tsx +++ b/cvat-ui/src/containers/file-manager/file-manager.tsx @@ -1,39 +1,53 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React from 'react'; import { connect } from 'react-redux'; +import { uniqBy } from 'lodash'; + import { TreeNodeNormal } from 'antd/lib/tree/Tree'; import FileManagerComponent, { Files } from 'components/file-manager/file-manager'; import { loadShareDataAsync } from 'actions/share-actions'; -import { ShareItem, CombinedState } from 'reducers'; +import { ShareItem, CombinedState, ShareFileInfo } from 'reducers'; interface OwnProps { ref: any; + many: boolean onChangeActiveKey(key: string): void; + onUploadLocalFiles(files: File[]): void; + onUploadRemoteFiles(urls: string[]): void; + onUploadShareFiles(shareFiles: { + key: string; + type: string; + mime_type: string; + }[]): void; + onUploadCloudStorageFiles(urls: string[]): void; } interface StateToProps { - treeData: TreeNodeNormal[]; + treeData: (TreeNodeNormal & { mime_type: string })[]; + share: any; } interface DispatchToProps { - getTreeData(key: string, success: () => void, failure: () => void): void; + getTreeData(key: string): Promise; } function mapStateToProps(state: CombinedState): StateToProps { - function convert(items: ShareItem[], path?: string): TreeNodeNormal[] { + function convert(items: ShareItem[], path?: string): (TreeNodeNormal & { mime_type: string })[] { return items.map( - (item): TreeNodeNormal => { + (item): (TreeNodeNormal & { mime_type: string }) => { const isLeaf = item.type !== 'DIR'; const key = `${path}${item.name}${isLeaf ? '' : '/'}`; return { key, isLeaf, title: item.name || 'root', + mime_type: item.mime_type, children: convert(item.children, key), }; }, @@ -41,16 +55,17 @@ function mapStateToProps(state: CombinedState): StateToProps { } const { root } = state.share; + return { treeData: convert([root], ''), + share: state.share, }; } function mapDispatchToProps(dispatch: any): DispatchToProps { return { - getTreeData: (key: string, success: () => void, failure: () => void): void => { - dispatch(loadShareDataAsync(key, success, failure)); - }, + getTreeData: (key: string): + Promise => dispatch(loadShareDataAsync(key)), }; } @@ -65,6 +80,62 @@ export class FileManagerContainer extends React.PureComponent { this.managerComponentRef = React.createRef(); } + private handleUploadShareFiles = (keys: string[]): Promise => new Promise(() => { + const { onUploadShareFiles, getTreeData } = this.props; + const getItemTreeDataByPath = (data: any, partsPath: string[]): any => { + if (partsPath.length === 1) return data.children.find((child: any) => child.title === partsPath[0]); + return getItemTreeDataByPath( + data.children.find((child: any) => child.title === partsPath[0]), + [...partsPath].filter((it, index) => index !== 0), + ); + }; + + const getShareFiles = async (localKeys: string[]): Promise<{ + key: string; + type: string; + mime_type: string; + }[]> => { + const { treeData } = this.props; + let files: { + key: string; + type: string; + mime_type: string; + }[] = []; + for await (const key of localKeys) { + const partsPath = key.split('/').filter(Boolean); + + const itemTreeData = partsPath.length ? getItemTreeDataByPath(treeData[0], partsPath) : treeData[0]; + if (itemTreeData.isLeaf) { + files = [...files, { + ...itemTreeData, + key, + type: itemTreeData.type, + mime_type: itemTreeData.mime_type, + }]; + } else { + const children: { + key: string; + type: string; + mime_type: string; + }[] = await getTreeData(key) + .then((items) => items.map( + (item): string => { + const isLeaf = item.type !== 'DIR'; + return `${key}${item.name}${isLeaf ? '' : '/'}`; + }, + )) + .then(getShareFiles); + files = [...files, ...children]; + } + } + return files; + }; + + getShareFiles(keys).then((data) => { + onUploadShareFiles(uniqBy(data, 'key')); + }); + }); + public getFiles(): Files { return this.managerComponentRef.getFiles(); } @@ -78,12 +149,27 @@ export class FileManagerContainer extends React.PureComponent { } public render(): JSX.Element { - const { treeData, getTreeData, onChangeActiveKey } = this.props; + const { + treeData, + share, + getTreeData, + many, + onChangeActiveKey, + onUploadLocalFiles, + onUploadRemoteFiles, + onUploadCloudStorageFiles, + } = this.props; return ( { this.managerComponentRef = component; diff --git a/cvat-ui/src/containers/tasks-page/tasks-page.tsx b/cvat-ui/src/containers/tasks-page/tasks-page.tsx index 89f906e8f07e..99264b567e22 100644 --- a/cvat-ui/src/containers/tasks-page/tasks-page.tsx +++ b/cvat-ui/src/containers/tasks-page/tasks-page.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -24,7 +25,7 @@ function mapStateToProps(state: CombinedState): StateToProps { countInvisible: tasks.hideEmpty ? tasks.current.filter((task: Task): boolean => !task.instance.jobs.length).length : 0, - importing: state.tasks.importing, + importing: state.import.tasks.backup.importing, }; } diff --git a/cvat-ui/src/cvat-canvas3d-wrapper.ts b/cvat-ui/src/cvat-canvas3d-wrapper.ts index 457cd7730b2b..a6d95279549a 100644 --- a/cvat-ui/src/cvat-canvas3d-wrapper.ts +++ b/cvat-ui/src/cvat-canvas3d-wrapper.ts @@ -1,4 +1,5 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -13,5 +14,7 @@ import { } from 'cvat-canvas3d/src/typescript/canvas3d'; export { - Canvas3d, Canvas3dVersion, MouseInteraction, ViewType, CameraAction, ViewsDOM, CanvasMode, + Canvas3d, Canvas3dVersion, MouseInteraction, ViewType, CameraAction, CanvasMode, }; + +export type { ViewsDOM }; diff --git a/cvat-ui/src/cvat-core-wrapper.ts b/cvat-ui/src/cvat-core-wrapper.ts index c9585d1a75c7..aa2120420a35 100644 --- a/cvat-ui/src/cvat-core-wrapper.ts +++ b/cvat-ui/src/cvat-core-wrapper.ts @@ -4,10 +4,12 @@ import _cvat from 'cvat-core/src/api'; import ObjectState from 'cvat-core/src/object-state'; +import Webhook from 'cvat-core/src/webhook'; import { Label, Attribute, RawAttribute, RawLabel, } from 'cvat-core/src/labels'; import { ShapeType } from 'cvat-core/src/enums'; +import { Storage, StorageData } from 'cvat-core/src/storage'; const cvat: any = _cvat; @@ -26,9 +28,12 @@ export { Label, Attribute, ShapeType, + Storage, + Webhook, }; export type { RawAttribute, RawLabel, + StorageData, }; diff --git a/cvat-ui/src/icons.tsx b/cvat-ui/src/icons.tsx index 36005a5040c9..2f5b09211639 100644 --- a/cvat-ui/src/icons.tsx +++ b/cvat-ui/src/icons.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -52,6 +53,7 @@ import SVGCVATAzureProvider from './assets/vscode-icons_file-type-azure.svg'; import SVGCVATS3Provider from './assets/S3.svg'; import SVGCVATGoogleCloudProvider from './assets/google-cloud.svg'; import SVGRestoreIcon from './assets/restore-icon.svg'; +import SVGMultiPlusIcon from './assets/multi-plus-icon.svg'; export const CVATLogo = React.memo((): JSX.Element => ); export const CursorIcon = React.memo((): JSX.Element => ); @@ -101,3 +103,4 @@ export const AzureProvider = React.memo((): JSX.Element => ); export const GoogleCloudProvider = React.memo((): JSX.Element => ); export const RestoreIcon = React.memo((): JSX.Element => ); +export const MutliPlusIcon = React.memo((): JSX.Element => ); diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 426def5689f0..ada2276328b9 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -937,19 +938,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }; } case AnnotationActionTypes.UPLOAD_JOB_ANNOTATIONS_SUCCESS: { - const { states, job, history } = action.payload; - const { loads } = state.activities; - - delete loads[job.id]; + const { states, history } = action.payload; return { ...state, - activities: { - ...state.activities, - loads: { - ...loads, - }, - }, annotations: { ...state.annotations, history, diff --git a/cvat-ui/src/reducers/export-reducer.ts b/cvat-ui/src/reducers/export-reducer.ts index 84c9d95d24fe..bd944e4e2faf 100644 --- a/cvat-ui/src/reducers/export-reducer.ts +++ b/cvat-ui/src/reducers/export-reducer.ts @@ -1,66 +1,180 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { ExportActions, ExportActionTypes } from 'actions/export-actions'; -import { getCore } from 'cvat-core-wrapper'; +import { omit } from 'lodash'; import deepCopy from 'utils/deep-copy'; import { ExportState } from '.'; - -const core = getCore(); +import { defineActititiesField } from './import-reducer'; const defaultState: ExportState = { - tasks: {}, - projects: {}, - instance: null, - modalVisible: false, + projects: { + dataset: { + current: {}, + modalInstance: null, + }, + backup: { + modalInstance: null, + current: {}, + }, + }, + tasks: { + dataset: { + current: {}, + modalInstance: null, + }, + backup: { + modalInstance: null, + current: {}, + }, + }, + jobs: { + dataset: { + current: {}, + modalInstance: null, + }, + }, + instanceType: null, }; export default (state: ExportState = defaultState, action: ExportActions): ExportState => { switch (action.type) { - case ExportActionTypes.OPEN_EXPORT_MODAL: + case ExportActionTypes.OPEN_EXPORT_DATASET_MODAL: { + const { instance } = action.payload; + const activitiesField = defineActititiesField(instance); + return { ...state, - modalVisible: true, - instance: action.payload.instance, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + modalInstance: instance, + }, + }, + instanceType: activitiesField + .slice(0, activitiesField.length - 1) as 'project' | 'task' | 'job', }; - case ExportActionTypes.CLOSE_EXPORT_MODAL: + } + case ExportActionTypes.CLOSE_EXPORT_DATASET_MODAL: { + const { instance } = action.payload; + const activitiesField = defineActititiesField(instance); + return { ...state, - modalVisible: false, - instance: null, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + modalInstance: null, + }, + }, + instanceType: null, }; + } case ExportActionTypes.EXPORT_DATASET: { const { instance, format } = action.payload; - const activities = deepCopy(instance instanceof core.classes.Project ? state.projects : state.tasks); - const instanceId = instance instanceof core.classes.Project || - instance instanceof core.classes.Task ? instance.id : instance.taskId; - - activities[instanceId] = - instanceId in activities && !activities[instanceId].includes(format) ? - [...activities[instanceId], format] : - activities[instanceId] || [format]; + const field = defineActititiesField(instance) as 'projects' | 'tasks' | 'jobs'; return { ...state, - ...(instance instanceof core.classes.Project ? { projects: activities } : { tasks: activities }), + [field]: { + ...state[field], + dataset: { + ...state[field].dataset, + current: { + ...state[field].dataset.current, + [instance.id]: !state[field].dataset.current[instance.id] ? [format] : + [...state[field].dataset.current[instance.id], format], + }, + }, + }, }; } case ExportActionTypes.EXPORT_DATASET_FAILED: case ExportActionTypes.EXPORT_DATASET_SUCCESS: { const { instance, format } = action.payload; - const activities = deepCopy(instance instanceof core.classes.Project ? state.projects : state.tasks); - const instanceId = instance instanceof core.classes.Project || - instance instanceof core.classes.Task ? instance.id : instance.taskId; + const field: 'projects' | 'tasks' | 'jobs' = defineActititiesField(instance); + const activities = deepCopy(state[field]); - activities[instanceId] = activities[instanceId].filter( + activities.dataset.current[instance.id] = activities.dataset.current[instance.id].filter( (exporterName: string): boolean => exporterName !== format, ); return { ...state, - ...(instance instanceof core.classes.Project ? { projects: activities } : { tasks: activities }), + [field]: activities, + }; + } + case ExportActionTypes.OPEN_EXPORT_BACKUP_MODAL: { + const { instance } = action.payload; + const field = defineActititiesField(instance) as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + modalInstance: instance, + }, + }, + instanceType: field + .slice(0, field.length - 1) as 'project' | 'task', + }; + } + case ExportActionTypes.CLOSE_EXPORT_BACKUP_MODAL: { + const { instance } = action.payload; + const field = defineActititiesField(instance) as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + modalInstance: null, + }, + }, + instanceType: null, + }; + } + case ExportActionTypes.EXPORT_BACKUP: { + const { instance } = action.payload; + const field = defineActititiesField(instance) as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + current: { + ...state[field].backup.current, + [instance.id]: true, + }, + }, + }, + }; + } + case ExportActionTypes.EXPORT_BACKUP_FAILED: + case ExportActionTypes.EXPORT_BACKUP_SUCCESS: { + const { instance } = action.payload; + + const field = defineActititiesField(instance) as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + current: omit(state[field].backup, instance.id), + }, + }, }; } default: diff --git a/cvat-ui/src/reducers/import-reducer.ts b/cvat-ui/src/reducers/import-reducer.ts index 17933501e97d..e91ad0cb64c1 100644 --- a/cvat-ui/src/reducers/import-reducer.ts +++ b/cvat-ui/src/reducers/import-reducer.ts @@ -1,58 +1,223 @@ // Copyright (C) 2021-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT +import { omit } from 'lodash'; import { ImportActions, ImportActionTypes } from 'actions/import-actions'; - +import { getCore } from 'cvat-core-wrapper'; import { ImportState } from '.'; +const core = getCore(); + +const defaultProgress = 0.0; + +export function defineActititiesField(instance: any): 'projects' | 'tasks' | 'jobs' { + if (instance instanceof core.classes.Project) { + return 'projects'; + } + if (instance instanceof core.classes.Task) { + return 'tasks'; + } + return 'jobs'; +} + const defaultState: ImportState = { - progress: 0.0, - status: '', - instance: null, - importingId: null, - modalVisible: false, + projects: { + dataset: { + modalInstance: null, + current: {}, + }, + backup: { + modalVisible: false, + importing: false, + }, + }, + tasks: { + dataset: { + modalInstance: null, + current: {}, + }, + backup: { + modalVisible: false, + importing: false, + }, + }, + jobs: { + dataset: { + modalInstance: null, + current: {}, + }, + }, + instanceType: null, }; export default (state: ImportState = defaultState, action: ImportActions): ImportState => { switch (action.type) { - case ImportActionTypes.OPEN_IMPORT_MODAL: + case ImportActionTypes.OPEN_IMPORT_DATASET_MODAL: { + const { instance } = action.payload; + const activitiesField = defineActititiesField(instance); + return { ...state, - modalVisible: true, - instance: action.payload.instance, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + modalInstance: instance, + }, + }, + instanceType: activitiesField + .slice(0, activitiesField.length - 1) as 'project' | 'task' | 'job', }; - case ImportActionTypes.CLOSE_IMPORT_MODAL: { + } + case ImportActionTypes.CLOSE_IMPORT_DATASET_MODAL: { + const { instance } = action.payload; + const activitiesField = defineActititiesField(instance); + return { ...state, - modalVisible: false, - instance: null, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + modalInstance: null, + }, + }, + instanceType: null, }; } case ImportActionTypes.IMPORT_DATASET: { - const { id } = action.payload; + const { format, instance } = action.payload; + + const activitiesField = defineActititiesField(instance); + let updatedActivity: { + format: string; + status?: string; + progress?: number; + } = { format }; + if (activitiesField === 'projects') { + updatedActivity = { + ...updatedActivity, + status: 'The file is being uploaded to the server', + progress: defaultProgress, + }; + } return { ...state, - importingId: id, - status: 'The file is being uploaded to the server', + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + current: { + ...state[activitiesField].dataset.current, + [instance.id]: updatedActivity, + }, + }, + }, }; } case ImportActionTypes.IMPORT_DATASET_UPDATE_STATUS: { - const { progress, status } = action.payload; + const { progress, status, instance } = action.payload; + + const activitiesField = defineActititiesField(instance); return { ...state, - progress, - status, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + current: { + ...state[activitiesField].dataset.current, + [instance.id]: { + ...state[activitiesField].dataset.current[instance.id] as Record, + progress, + status, + }, + }, + }, + }, }; } case ImportActionTypes.IMPORT_DATASET_FAILED: case ImportActionTypes.IMPORT_DATASET_SUCCESS: { + const { instance } = action.payload; + const activitiesField = defineActititiesField(instance); + const { current } = state[activitiesField].dataset; + + return { + ...state, + [activitiesField]: { + ...state[activitiesField], + dataset: { + ...state[activitiesField].dataset, + current: omit(current, instance.id), + }, + }, + }; + } + case ImportActionTypes.OPEN_IMPORT_BACKUP_MODAL: { + const { instanceType } = action.payload; + const field = `${instanceType}s` as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + modalVisible: true, + importing: false, + }, + }, + instanceType, + }; + } + case ImportActionTypes.CLOSE_IMPORT_BACKUP_MODAL: { + const { instanceType } = action.payload; + const field = `${instanceType}s` as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + modalVisible: false, + }, + }, + instanceType: null, + }; + } + case ImportActionTypes.IMPORT_BACKUP: { + const { instanceType } = state; + const field = `${instanceType}s` as 'projects' | 'tasks'; + + return { + ...state, + [field]: { + ...state[field], + backup: { + ...state[field].backup, + importing: true, + }, + }, + }; + } + case ImportActionTypes.IMPORT_BACKUP_FAILED: + case ImportActionTypes.IMPORT_BACKUP_SUCCESS: { + const { instanceType } = action.payload; + const field = `${instanceType}s` as 'projects' | 'tasks'; + return { ...state, - progress: defaultState.progress, - status: defaultState.status, - importingId: null, + [`${instanceType}s`]: { + ...state[field], + backup: { + ...state[field].backup, + importing: false, + }, + }, }; } default: diff --git a/cvat-ui/src/reducers/index.ts b/cvat-ui/src/reducers/index.ts index cdba4e342a54..acbd52454b75 100644 --- a/cvat-ui/src/reducers/index.ts +++ b/cvat-ui/src/reducers/index.ts @@ -1,9 +1,11 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { Canvas3d } from 'cvat-canvas3d/src/typescript/canvas3d'; import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper'; +import { Webhook } from 'cvat-core-wrapper'; import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors'; import { KeyMap } from 'utils/mousetrap-react'; import { OpenCVTracker } from 'utils/opencv-wrapper/opencv-interfaces'; @@ -51,11 +53,7 @@ export interface ProjectsState { deletes: { [projectId: number]: boolean; // deleted (deleting if in dictionary) }; - backups: { - [projectId: number]: boolean; - } }; - restoring: boolean; } export interface TasksQuery { @@ -88,7 +86,6 @@ export interface JobsState { } export interface TasksState { - importing: boolean; initialized: boolean; fetching: boolean; updating: boolean; @@ -101,21 +98,9 @@ export interface TasksState { count: number; current: Task[]; activities: { - loads: { - // only one loading simultaneously - [tid: number]: string; // loader name - }; deletes: { [tid: number]: boolean; // deleted (deleting if in dictionary) }; - creates: { - taskId: number | null; - status: string; - error: string; - }; - backups: { - [tid: number]: boolean; - }; jobUpdates: { [jid: number]: boolean, }; @@ -123,22 +108,83 @@ export interface TasksState { } export interface ExportState { + projects: { + dataset: { + current: { + [id: number]: string[]; + }; + modalInstance: any | null; + }; + backup: { + current: { + [id: number]: boolean; + }; + modalInstance: any | null; + }; + }; tasks: { - [tid: number]: string[]; + dataset: { + current: { + [id: number]: string[]; + }; + modalInstance: any | null; + }; + backup: { + current: { + [id: number]: boolean; + }; + modalInstance: any | null; + }; }; - projects: { - [pid: number]: string[]; + jobs: { + dataset: { + current: { + [id: number]: string[]; + }; + modalInstance: any | null; + }; }; - instance: any; - modalVisible: boolean; + instanceType: 'project' | 'task' | 'job' | null; } export interface ImportState { - importingId: number | null; - progress: number; - status: string; - instance: any; - modalVisible: boolean; + projects: { + dataset: { + modalInstance: any | null; + current: { + [id: number]: { + format: string; + progress: number; + status: string; + }; + }; + }; + backup: { + modalVisible: boolean; + importing: boolean; + } + }; + tasks: { + dataset: { + modalInstance: any | null; + current: { + [id: number]: string; + }; + }; + backup: { + modalVisible: boolean; + importing: boolean; + } + }; + jobs: { + dataset: { + modalInstance: any | null; + current: { + [id: number]: string; + }; + }; + }; + instanceType: 'project' | 'task' | 'job' | null; } export interface FormatsState { @@ -246,11 +292,13 @@ export interface ShareFileInfo { // get this data from cvat-core name: string; type: 'DIR' | 'REG'; + mime_type: string; } export interface ShareItem { name: string; type: 'DIR' | 'REG'; + mime_type: string; children: ShareItem[]; } @@ -438,10 +486,12 @@ export interface NotificationsState { exporting: { dataset: null | ErrorState; annotation: null | ErrorState; + backup: null | ErrorState; }; importing: { dataset: null | ErrorState; annotation: null | ErrorState; + backup: null | ErrorState; }; cloudStorages: { creating: null | ErrorState; @@ -460,6 +510,12 @@ export interface NotificationsState { updatingMembership: null | ErrorState; removingMembership: null | ErrorState; }; + webhooks: { + fetching: null | ErrorState; + creating: null | ErrorState; + updating: null | ErrorState; + deleting: null | ErrorState; + }; }; messages: { tasks: { @@ -478,7 +534,17 @@ export interface NotificationsState { }; projects: { restoringDone: string; - } + }; + exporting: { + dataset: string; + annotation: string; + backup: string; + }; + importing: { + dataset: string; + annotation: string; + backup: string; + }; }; } @@ -740,6 +806,11 @@ export interface ShortcutsState { normalizedKeyMap: Record; } +export enum StorageLocation { + LOCAL = 'local', + CLOUD_STORAGE = 'cloud_storage', +} + export enum ReviewStatus { ACCEPTED = 'accepted', REJECTED = 'rejected', @@ -772,6 +843,22 @@ export interface OrganizationState { updatingMember: boolean; } +export interface WebhooksQuery { + page: number; + id: number | null; + search: string | null; + filter: string | null; + sort: string | null; + projectId: number | null; +} + +export interface WebhooksState { + current: Webhook[], + totalCount: number; + fetching: boolean; + query: WebhooksQuery; +} + export interface CombinedState { auth: AuthState; projects: ProjectsState; @@ -792,6 +879,7 @@ export interface CombinedState { import: ImportState; cloudStorages: CloudStoragesState; organizations: OrganizationState; + webhooks: WebhooksState; } export enum DimensionType { diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index d3cf440cf587..c81778b00554 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -21,12 +22,10 @@ import { ImportActionTypes } from 'actions/import-actions'; import { CloudStorageActionTypes } from 'actions/cloud-storage-actions'; import { OrganizationActionsTypes } from 'actions/organization-actions'; import { JobsActionTypes } from 'actions/jobs-actions'; +import { WebhooksActionsTypes } from 'actions/webhooks-actions'; -import { getCore } from 'cvat-core-wrapper'; import { NotificationsState } from '.'; -const core = getCore(); - const defaultState: NotificationsState = { errors: { auth: { @@ -128,10 +127,12 @@ const defaultState: NotificationsState = { exporting: { dataset: null, annotation: null, + backup: null, }, importing: { dataset: null, annotation: null, + backup: null, }, cloudStorages: { creating: null, @@ -150,6 +151,12 @@ const defaultState: NotificationsState = { updatingMembership: null, removingMembership: null, }, + webhooks: { + fetching: null, + creating: null, + updating: null, + deleting: null, + }, }, messages: { tasks: { @@ -169,6 +176,16 @@ const defaultState: NotificationsState = { projects: { restoringDone: '', }, + exporting: { + dataset: '', + annotation: '', + backup: '', + }, + importing: { + dataset: '', + annotation: '', + backup: '', + }, }, }; @@ -353,8 +370,7 @@ export default function (state = defaultState, action: AnyAction): Notifications }; } case ExportActionTypes.EXPORT_DATASET_FAILED: { - const instanceID = action.payload.instance.id; - const instanceType = action.payload.instance instanceof core.classes.Project ? 'project' : 'task'; + const { instance, instanceType } = action.payload; return { ...state, errors: { @@ -364,173 +380,203 @@ export default function (state = defaultState, action: AnyAction): Notifications dataset: { message: 'Could not export dataset for the ' + - `` + - `${instanceType} ${instanceID}`, + `` + + `${instanceType} ${instance.id}`, reason: action.payload.error.toString(), }, }, }, }; } - case ImportActionTypes.IMPORT_DATASET_FAILED: { - const instanceID = action.payload.instance.id; + case ExportActionTypes.EXPORT_DATASET_SUCCESS: { + const { + instance, instanceType, isLocal, resource, + } = action.payload; + const auxiliaryVerb = resource === 'Dataset' ? 'has' : 'have'; return { ...state, - errors: { - ...state.errors, + messages: { + ...state.messages, exporting: { - ...state.errors.exporting, - dataset: { - message: - 'Could not import dataset to the ' + - `` + - `project ${instanceID}`, - reason: action.payload.error.toString(), - }, + ...state.messages.exporting, + dataset: + `${resource} for ${instanceType} ${instance.id} ` + + `${auxiliaryVerb} been ${(isLocal) ? 'downloaded' : 'uploaded'} ` + + `${(isLocal) ? 'locally' : 'to cloud storage'}`, }, }, }; } - case TasksActionTypes.GET_TASKS_FAILED: { + case ExportActionTypes.EXPORT_BACKUP_FAILED: { + const { instance, instanceType } = action.payload; return { ...state, errors: { ...state.errors, - tasks: { - ...state.errors.tasks, - fetching: { - message: 'Could not fetch tasks', + exporting: { + ...state.errors.exporting, + backup: { + message: + `Could not export the ${instanceType} №${instance.id}`, reason: action.payload.error.toString(), }, }, }, }; } - case TasksActionTypes.LOAD_ANNOTATIONS_FAILED: { - const taskID = action.payload.task.id; + case ExportActionTypes.EXPORT_BACKUP_SUCCESS: { + const { instance, instanceType, isLocal } = action.payload; return { ...state, - errors: { - ...state.errors, - tasks: { - ...state.errors.tasks, - loading: { - message: - 'Could not upload annotation for the ' + - `task ${taskID}`, - reason: action.payload.error.toString(), - className: 'cvat-notification-notice-load-annotation-failed', - }, + messages: { + ...state.messages, + exporting: { + ...state.messages.exporting, + backup: + `Backup for the ${instanceType} №${instance.id} ` + + `has been ${(isLocal) ? 'downloaded' : 'uploaded'} ` + + `${(isLocal) ? 'locally' : 'to cloud storage'}`, }, }, }; } - case TasksActionTypes.LOAD_ANNOTATIONS_SUCCESS: { - const taskID = action.payload.task.id; + case ImportActionTypes.IMPORT_DATASET_SUCCESS: { + const { instance, resource } = action.payload; + const message = resource === 'annotation' ? + 'Annotations have been loaded to the ' + + `` + + `task ${instance.taskId || instance.id}` : + 'Dataset has been imported to the ' + + `project ${instance.id}`; return { ...state, messages: { ...state.messages, - tasks: { - ...state.messages.tasks, - loadingDone: - 'Annotations have been loaded to the ' + - `task ${taskID}`, + importing: { + ...state.messages.importing, + [resource]: message, }, }, }; } - case TasksActionTypes.UPDATE_TASK_FAILED: { - const taskID = action.payload.task.id; + case ImportActionTypes.IMPORT_DATASET_FAILED: { + const { instance, resource } = action.payload; + const message = resource === 'annotation' ? 'Could not upload annotation for the ' + + `` : + 'Could not import dataset to the ' + + `` + + `project ${instance.id}`; return { ...state, errors: { ...state.errors, - tasks: { - ...state.errors.tasks, - updating: { - message: `Could not update task ${taskID}`, + importing: { + ...state.errors.importing, + dataset: { + message, reason: action.payload.error.toString(), - className: 'cvat-notification-notice-update-task-failed', + className: 'cvat-notification-notice-' + + `${resource === 'annotation' ? 'load-annotation' : 'import-dataset'}-failed`, }, }, }, }; } - case TasksActionTypes.DELETE_TASK_FAILED: { - const { taskID } = action.payload; + case ImportActionTypes.IMPORT_BACKUP_SUCCESS: { + const { instanceId, instanceType } = action.payload; + return { + ...state, + messages: { + ...state.messages, + importing: { + ...state.messages.importing, + backup: + `The ${instanceType} has been restored succesfully. + Click here to open`, + }, + }, + }; + } + case ImportActionTypes.IMPORT_BACKUP_FAILED: { + const { instanceType } = action.payload; return { ...state, errors: { ...state.errors, - tasks: { - ...state.errors.tasks, - deleting: { + importing: { + ...state.errors.importing, + backup: { message: - 'Could not delete the ' + - `task ${taskID}`, + `Could not restore ${instanceType} backup.`, reason: action.payload.error.toString(), - className: 'cvat-notification-notice-delete-task-failed', }, }, }, }; } - case TasksActionTypes.CREATE_TASK_FAILED: { + case TasksActionTypes.GET_TASKS_FAILED: { return { ...state, errors: { ...state.errors, tasks: { ...state.errors.tasks, - creating: { - message: 'Could not create the task', + fetching: { + message: 'Could not fetch tasks', reason: action.payload.error.toString(), - className: 'cvat-notification-notice-create-task-failed', }, }, }, }; } - case TasksActionTypes.EXPORT_TASK_FAILED: { + case TasksActionTypes.UPDATE_TASK_FAILED: { + const taskID = action.payload.task.id; return { ...state, errors: { ...state.errors, tasks: { ...state.errors.tasks, - exporting: { - message: 'Could not export the task', + updating: { + message: `Could not update task ${taskID}`, reason: action.payload.error.toString(), + className: 'cvat-notification-notice-update-task-failed', }, }, }, }; } - case TasksActionTypes.IMPORT_TASK_FAILED: { + case TasksActionTypes.DELETE_TASK_FAILED: { + const { taskID } = action.payload; return { ...state, errors: { ...state.errors, tasks: { ...state.errors.tasks, - importing: { - message: 'Could not import the task', + deleting: { + message: + 'Could not delete the ' + + `task ${taskID}`, reason: action.payload.error.toString(), + className: 'cvat-notification-notice-delete-task-failed', }, }, }, }; } - case TasksActionTypes.IMPORT_TASK_SUCCESS: { - const taskID = action.payload.task.id; + case TasksActionTypes.CREATE_TASK_FAILED: { return { ...state, - messages: { - ...state.messages, + errors: { + ...state.errors, tasks: { - ...state.messages.tasks, - importingDone: `Task has been imported succesfully Open task`, + ...state.errors.tasks, + creating: { + message: 'Could not create the task', + reason: action.payload.error.toString(), + className: 'cvat-notification-notice-create-task-failed', + }, }, }, }; @@ -621,51 +667,6 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - case ProjectsActionTypes.BACKUP_PROJECT_FAILED: { - return { - ...state, - errors: { - ...state.errors, - projects: { - ...state.errors.projects, - backuping: { - message: `Could not backup the project #${action.payload.projectId}`, - reason: action.payload.error.toString(), - }, - }, - }, - }; - } - case ProjectsActionTypes.RESTORE_PROJECT_FAILED: { - return { - ...state, - errors: { - ...state.errors, - projects: { - ...state.errors.projects, - restoring: { - message: 'Could not restore the project', - reason: action.payload.error.toString(), - }, - }, - }, - }; - } - case ProjectsActionTypes.RESTORE_PROJECT_SUCCESS: { - const { projectID } = action.payload; - return { - ...state, - messages: { - ...state.messages, - projects: { - ...state.messages.projects, - restoringDone: - `Project has been created succesfully. - Click here to open`, - }, - }, - }; - } case FormatsActionTypes.GET_FORMATS_FAILED: { return { ...state, @@ -1627,6 +1628,70 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case WebhooksActionsTypes.GET_WEBHOOKS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + webhooks: { + ...state.errors.webhooks, + fetching: { + message: 'Could not fetch a list of webhooks', + reason: action.payload.error.toString(), + className: 'cvat-notification-notice-get-webhooks-failed', + }, + }, + }, + }; + } + case WebhooksActionsTypes.CREATE_WEBHOOK_FAILED: { + return { + ...state, + errors: { + ...state.errors, + webhooks: { + ...state.errors.webhooks, + creating: { + message: 'Could not create webhook', + reason: action.payload.error.toString(), + className: 'cvat-notification-notice-create-webhook-failed', + }, + }, + }, + }; + } + case WebhooksActionsTypes.UPDATE_WEBHOOK_FAILED: { + return { + ...state, + errors: { + ...state.errors, + webhooks: { + ...state.errors.webhooks, + updating: { + message: 'Could not update webhook', + reason: action.payload.error.toString(), + className: 'cvat-notification-notice-update-webhook-failed', + }, + }, + }, + }; + } + case WebhooksActionsTypes.DELETE_WEBHOOK_FAILED: { + return { + ...state, + errors: { + ...state.errors, + webhooks: { + ...state.errors.webhooks, + deleting: { + message: 'Could not delete webhook', + reason: action.payload.error.toString(), + className: 'cvat-notification-notice-delete-webhook-failed', + }, + }, + }, + }; + } case BoundariesActionTypes.RESET_AFTER_ERROR: case AuthActionTypes.LOGOUT_SUCCESS: { return { ...defaultState }; diff --git a/cvat-ui/src/reducers/projects-reducer.ts b/cvat-ui/src/reducers/projects-reducer.ts index c9a6f47259c1..7f416b2dd5df 100644 --- a/cvat-ui/src/reducers/projects-reducer.ts +++ b/cvat-ui/src/reducers/projects-reducer.ts @@ -1,9 +1,9 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import { AnyAction } from 'redux'; -import { omit } from 'lodash'; import { ProjectsActionTypes } from 'actions/projects-actions'; import { BoundariesActionTypes } from 'actions/boundaries-actions'; import { AuthActionTypes } from 'actions/auth-actions'; @@ -37,9 +37,7 @@ const defaultState: ProjectsState = { id: null, error: '', }, - backups: {}, }, - restoring: false, }; export default (state: ProjectsState = defaultState, action: AnyAction): ProjectsState => { @@ -204,48 +202,6 @@ export default (state: ProjectsState = defaultState, action: AnyAction): Project }, }; } - case ProjectsActionTypes.BACKUP_PROJECT: { - const { projectId } = action.payload; - const { backups } = state.activities; - - return { - ...state, - activities: { - ...state.activities, - backups: { - ...backups, - ...Object.fromEntries([[projectId, true]]), - }, - }, - }; - } - case ProjectsActionTypes.BACKUP_PROJECT_FAILED: - case ProjectsActionTypes.BACKUP_PROJECT_SUCCESS: { - const { projectID } = action.payload; - const { backups } = state.activities; - - return { - ...state, - activities: { - ...state.activities, - backups: omit(backups, [projectID]), - }, - }; - } - case ProjectsActionTypes.RESTORE_PROJECT: { - return { - ...state, - restoring: true, - }; - } - case ProjectsActionTypes.RESTORE_PROJECT_FAILED: - case ProjectsActionTypes.RESTORE_PROJECT_SUCCESS: { - return { - ...state, - restoring: false, - }; - } - case BoundariesActionTypes.RESET_AFTER_ERROR: case AuthActionTypes.LOGOUT_SUCCESS: { return { ...defaultState }; diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index 53b91afca5d9..1371015bbce4 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -22,6 +23,7 @@ import exportReducer from './export-reducer'; import importReducer from './import-reducer'; import cloudStoragesReducer from './cloud-storages-reducer'; import organizationsReducer from './organizations-reducer'; +import webhooksReducer from './webhooks-reducer'; export default function createRootReducer(): Reducer { return combineReducers({ @@ -44,5 +46,6 @@ export default function createRootReducer(): Reducer { import: importReducer, cloudStorages: cloudStoragesReducer, organizations: organizationsReducer, + webhooks: webhooksReducer, }); } diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts index 716b64685fd8..ab39c697c351 100644 --- a/cvat-ui/src/reducers/tasks-reducer.ts +++ b/cvat-ui/src/reducers/tasks-reducer.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -31,17 +32,9 @@ const defaultState: TasksState = { projectId: null, }, activities: { - loads: {}, deletes: {}, - creates: { - taskId: null, - status: '', - error: '', - }, - backups: {}, jobUpdates: {}, }, - importing: false, }; export default (state: TasksState = defaultState, action: AnyAction): TasksState => { @@ -82,40 +75,6 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState initialized: true, fetching: false, }; - case TasksActionTypes.LOAD_ANNOTATIONS: { - const { task } = action.payload; - const { loader } = action.payload; - const { loads } = state.activities; - - loads[task.id] = task.id in loads ? loads[task.id] : loader.name; - - return { - ...state, - activities: { - ...state.activities, - loads: { - ...loads, - }, - }, - }; - } - case TasksActionTypes.LOAD_ANNOTATIONS_FAILED: - case TasksActionTypes.LOAD_ANNOTATIONS_SUCCESS: { - const { task } = action.payload; - const { loads } = state.activities; - - delete loads[task.id]; - - return { - ...state, - activities: { - ...state.activities, - loads: { - ...loads, - }, - }, - }; - } case TasksActionTypes.DELETE_TASK: { const { taskID } = action.payload; const { deletes } = state.activities; @@ -164,103 +123,6 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState }, }; } - case TasksActionTypes.EXPORT_TASK: { - const { taskID } = action.payload; - const { backups } = state.activities; - - return { - ...state, - activities: { - ...state.activities, - backups: { - ...backups, - ...Object.fromEntries([[taskID, true]]), - }, - }, - }; - } - case TasksActionTypes.EXPORT_TASK_FAILED: - case TasksActionTypes.EXPORT_TASK_SUCCESS: { - const { taskID } = action.payload; - const { backups } = state.activities; - - delete backups[taskID]; - - return { - ...state, - activities: { - ...state.activities, - backups: omit(backups, [taskID]), - }, - }; - } - case TasksActionTypes.IMPORT_TASK: { - return { - ...state, - importing: true, - }; - } - case TasksActionTypes.IMPORT_TASK_FAILED: - case TasksActionTypes.IMPORT_TASK_SUCCESS: { - return { - ...state, - importing: false, - }; - } - case TasksActionTypes.CREATE_TASK: { - return { - ...state, - activities: { - ...state.activities, - creates: { - taskId: null, - status: '', - error: '', - }, - }, - }; - } - case TasksActionTypes.CREATE_TASK_STATUS_UPDATED: { - const { status } = action.payload; - - return { - ...state, - activities: { - ...state.activities, - creates: { - ...state.activities.creates, - status, - }, - }, - }; - } - case TasksActionTypes.CREATE_TASK_SUCCESS: { - const { taskId } = action.payload; - return { - ...state, - activities: { - ...state.activities, - creates: { - ...state.activities.creates, - taskId, - status: 'CREATED', - }, - }, - }; - } - case TasksActionTypes.CREATE_TASK_FAILED: { - return { - ...state, - activities: { - ...state.activities, - creates: { - ...state.activities.creates, - status: 'FAILED', - error: action.payload.error.toString(), - }, - }, - }; - } case TasksActionTypes.UPDATE_TASK: { return { ...state, diff --git a/cvat-ui/src/reducers/webhooks-reducer.ts b/cvat-ui/src/reducers/webhooks-reducer.ts new file mode 100644 index 000000000000..6b201b3eadcf --- /dev/null +++ b/cvat-ui/src/reducers/webhooks-reducer.ts @@ -0,0 +1,56 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { AuthActions, AuthActionTypes } from 'actions/auth-actions'; +import { WebhooksActions, WebhooksActionsTypes } from 'actions/webhooks-actions'; +import { WebhooksState } from 'reducers'; + +const defaultState: WebhooksState = { + current: [], + totalCount: 0, + query: { + page: 1, + id: null, + projectId: null, + search: null, + filter: null, + sort: null, + }, + fetching: false, +}; + +export default function ( + state: WebhooksState = defaultState, + action: WebhooksActions | AuthActions, +): WebhooksState { + switch (action.type) { + case WebhooksActionsTypes.GET_WEBHOOKS: { + return { + ...state, + fetching: true, + query: { + ...state.query, + ...action.payload.query, + }, + }; + } + case WebhooksActionsTypes.GET_WEBHOOKS_SUCCESS: + return { + ...state, + fetching: false, + totalCount: action.payload.count, + current: action.payload.webhooks, + }; + case WebhooksActionsTypes.GET_WEBHOOKS_FAILED: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.LOGOUT_SUCCESS: { + return { ...defaultState }; + } + default: + return state; + } +} diff --git a/cvat-ui/src/styles.scss b/cvat-ui/src/styles.scss index ce548be7205a..befdaf2547e6 100644 --- a/cvat-ui/src/styles.scss +++ b/cvat-ui/src/styles.scss @@ -96,3 +96,13 @@ hr { .cvat-divider { margin: $grid-unit-size * 0.5 0; } + +.cvat-advanced-configuration-wrapper { + .ant-collapse-header { + align-items: center !important; + } + + .cvat-title { + padding-top: 0; + } +} diff --git a/cvat-ui/src/utils/files.ts b/cvat-ui/src/utils/files.ts new file mode 100644 index 000000000000..bae75f830d66 --- /dev/null +++ b/cvat-ui/src/utils/files.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +export function getFileContentTypeByMimeType(mime_type: string): string { + return mime_type.split('/')[0]; +} + +export function getFileContentType(file: File): string { + return getFileContentTypeByMimeType(file.type); +} + +export function checkFileTypesEqual(files: File[]): boolean { + if (!files.length) return true; + const typeFirstFile: string = getFileContentType(files[0]); + return files.every((file) => getFileContentType(file) === typeFirstFile); +} + +function getUrlExtension(url: string): string { + return (url.split(/[#?]/)[0].split('.').pop()?.trim() || '').toLowerCase(); +} + +// source https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +const IMAGE_EXTENSIONS = ['3ds', 'ag', 'arw', 'bay', 'bmp', 'bmq', 'cgm', 'cr2', 'crw', 'cs1', 'cs2', 'cur', 'dcr', + 'dds', 'djv', 'djvu', 'dng', 'dwg', 'dxf', 'emf', 'eps', 'eps.bz2', 'eps.gz', 'epsf', 'epsf.bz2', 'epsf.gz', + 'epsi', 'epsi.bz2', 'epsi.gz', 'erf', 'exr', 'fff', 'fig', 'fits', 'g3', 'gif', 'hdr', 'hrd', 'icb', + 'icns', 'ico', 'ief', 'iff', 'ilbm', 'jng', 'jp2', 'jpe', 'jpeg', 'jpf', 'jpg', 'jpx', 'k25', 'kdc', + 'lbm', 'lwo', 'lwob', 'lws', 'mdc', 'mdi', 'mos', 'mrw.', 'msod', 'nef', 'ora', 'orf', 'pbm', 'pct', 'pcx', + 'pef', 'pgm', 'pic', 'pict', 'pict1', 'pict2', 'png', 'pnm', 'pntg', 'pnx', 'ppm', 'psd', 'qif', 'qtif', 'raf', + 'ras', 'raw', 'rdc', 'rgb', 'rle', 'rp', 'rw2', 'sgi', 'sk', 'sk1', 'sr2', 'srf', 'sun', 'svg', 'svgz', 'tga', + 'tif', 'tiff', 'tpic', 'vda', 'vst', 'wbmp', 'webp', 'wmf', 'x3f', 'xbm', 'xcf', 'xcf.bz2', 'xcf.gz', 'xpm', 'xwd', +]; + +// source https://en.wikipedia.org/wiki/Video_file_format +const VIDEO_EXTENSIONS = ['3g2', '3ga', '3gp', '3gp2', '3gpp', '3gpp2', 'amv', 'asf', 'avf', 'avi', 'axv', 'bdm', + 'bdmv', 'clpi', 'cpi', 'divx', 'drc', 'dv', 'f4a', 'f4b', 'f4p', 'f4v', 'flc', 'fli', 'flv', 'fxm', 'gifv', + 'lrv', 'm1u', 'm2t', 'm2ts', 'm2v', 'm4p', 'm4u', 'm4v', 'mk3d', 'mkv', 'mng', 'moov', 'mov', 'movie', + 'mp2', 'mp4', 'mpe', 'mpeg', 'mpg', 'mpl', 'mpls', 'mpv', 'mts', 'mxf', 'mxu', 'nsv', 'ogg', 'ogm', 'ogv', + 'qt', 'qtvr', 'rm', 'rmvb', 'roq', 'rv', 'rvx', 'svi', 'ts', 'vdr', 'viv', 'vivo', 'vob', 'webm', 'wmp', 'wmv', 'yuv', +]; + +export function getContentTypeRemoteFile(url: string): 'image' | 'video' | 'unknown' { + const extention = getUrlExtension(url); + if (IMAGE_EXTENSIONS.includes(extention)) { + return 'image'; + } + + if (VIDEO_EXTENSIONS.includes(extention)) { + return 'video'; + } + + return 'unknown'; +} + +export function getFileNameFromPath(path: string): string { + return path.split('/').filter(Boolean).pop()?.split(/[#?]/)?.[0] || ''; +} diff --git a/cvat/__init__.py b/cvat/__init__.py index 62255fd107c3..146227fe7645 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (2, 2, 0, 'alpha', 0) +VERSION = (2, 3, 0, 'alpha', 0) __version__ = get_version(VERSION) diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index b416bcae85c8..1f1177b8c840 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -1,5 +1,6 @@ # Copyright (C) 2019-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -12,13 +13,10 @@ from typing import (Any, Callable, DefaultDict, Dict, List, Literal, Mapping, NamedTuple, OrderedDict, Set, Tuple, Union) -import datumaro.components.annotation as datum_annotation -import datumaro.components.extractor as datum_extractor +import datumaro as dm import rq from attr import attrib, attrs -from datumaro.components.dataset import Dataset -from datumaro.util import cast -from datumaro.util.image import ByteImage, Image +from datumaro.components.media import PointCloud from django.utils import timezone from cvat.apps.dataset_manager.formats.utils import get_label_color @@ -241,7 +239,7 @@ def meta_for_task(db_task, host, label_mapping=None): ("bugtracker", db_task.bug_tracker), ("created", str(timezone.localtime(db_task.created_date))), ("updated", str(timezone.localtime(db_task.updated_date))), - ("subset", db_task.subset or datum_extractor.DEFAULT_SUBSET_NAME), + ("subset", db_task.subset or dm.DEFAULT_SUBSET_NAME), ("start_frame", str(db_task.data.start_frame)), ("stop_frame", str(db_task.data.stop_frame)), ("frame_filter", db_task.data.frame_filter), @@ -526,9 +524,7 @@ def _import_track(self, track, parent_label_id=None): ] shape['attributes'] = [self._import_attribute(label_id, attrib, mutable=True) for attrib in shape['attributes'] - if self._get_mutable_attribute_id(label_id, attrib.name) or ( - self.soft_attribute_import and attrib.name not in CVAT_INTERNAL_ATTRIBUTES - ) + if self._get_mutable_attribute_id(label_id, attrib.name) ] shape['points'] = list(map(float, shape['points'])) @@ -781,7 +777,7 @@ def _init_meta(self): ) for db_task in self._db_tasks.values() ]), - ("subsets", '\n'.join([s if s else datum_extractor.DEFAULT_SUBSET_NAME for s in self._subsets])), + ("subsets", '\n'.join([s if s else dm.DEFAULT_SUBSET_NAME for s in self._subsets])), ("owner", OrderedDict([ ("username", self._db_project.owner.username), @@ -1024,7 +1020,7 @@ def task_data(self): def _get_filename(path): return osp.splitext(path)[0] - def match_frame(self, path: str, subset: str=datum_extractor.DEFAULT_SUBSET_NAME, root_hint: str=None, path_has_ext: bool=True): + def match_frame(self, path: str, subset: str=dm.DEFAULT_SUBSET_NAME, root_hint: str=None, path_has_ext: bool=True): if path_has_ext: path = self._get_filename(path) match_task, match_frame = self._frame_mapping.get((subset, path), (None, None)) @@ -1040,11 +1036,11 @@ def match_frame_fuzzy(self, path): return frame_number return None - def split_dataset(self, dataset: Dataset): + def split_dataset(self, dataset: dm.Dataset): for task_data in self.task_data: if task_data._db_task.id not in self.new_tasks: continue - subset_dataset: Dataset = dataset.subsets()[task_data.db_task.subset].as_dataset() + subset_dataset: dm.Dataset = dataset.subsets()[task_data.db_task.subset].as_dataset() yield subset_dataset, task_data def add_labels(self, labels: List[dict]): @@ -1060,7 +1056,7 @@ def add_task(self, task, files): self._project_annotation.add_task(task, files, self) class CVATDataExtractorMixin: - def __init__(self): + def __init__(self, media_type=dm.Image): super().__init__() def categories(self) -> dict: @@ -1068,28 +1064,27 @@ def categories(self) -> dict: @staticmethod def _load_categories(labels: list): - categories: Dict[datum_annotation.AnnotationType, - datum_annotation.Categories] = {} + categories: Dict[dm.AnnotationType, + dm.Categories] = {} - label_categories = datum_annotation.LabelCategories(attributes=['occluded']) - point_categories = datum_annotation.PointsCategories() + label_categories = dm.LabelCategories(attributes=['occluded']) + point_categories = dm.PointsCategories() for _, label in labels: - if label.get('parent') is None: - label_id = label_categories.add(label['name']) - for _, attr in label['attributes']: - label_categories.attributes.add(attr['name']) + label_id = label_categories.add(label['name'], label.get('parent')) + for _, attr in label['attributes']: + label_categories.attributes.add(attr['name']) - if label['type'] == str(LabelType.SKELETON): - labels_from = list(map(int, re.findall(r'data-node-from="(\d+)"', label['svg']))) - labels_to = list(map(int, re.findall(r'data-node-to="(\d+)"', label['svg']))) - sublabels = re.findall(r'data-label-name="(\w+)"', label['svg']) - joints = zip(labels_from, labels_to) + if label['type'] == str(LabelType.SKELETON): + labels_from = list(map(int, re.findall(r'data-node-from="(\d+)"', label['svg']))) + labels_to = list(map(int, re.findall(r'data-node-to="(\d+)"', label['svg']))) + sublabels = re.findall(r'data-label-name="(\w+)"', label['svg']) + joints = zip(labels_from, labels_to) - point_categories.add(label_id, sublabels, joints) + point_categories.add(label_id, sublabels, joints) - categories[datum_annotation.AnnotationType.label] = label_categories - categories[datum_annotation.AnnotationType.points] = point_categories + categories[dm.AnnotationType.label] = label_categories + categories[dm.AnnotationType.points] = point_categories return categories @@ -1103,19 +1098,19 @@ def _load_user_info(meta: dict): def _read_cvat_anno(self, cvat_frame_anno: Union[ProjectData.Frame, TaskData.Frame], labels: list): categories = self.categories() - label_cat = categories[datum_annotation.AnnotationType.label] - def map_label(name): return label_cat.find(name)[0] + label_cat = categories[dm.AnnotationType.label] + def map_label(name, parent=''): return label_cat.find(name, parent)[0] label_attrs = { - label['name']: label['attributes'] + label.get('parent', '') + label['name']: label['attributes'] for _, label in labels } return convert_cvat_anno_to_dm(cvat_frame_anno, label_attrs, map_label) -class CvatTaskDataExtractor(datum_extractor.SourceExtractor, CVATDataExtractorMixin): +class CvatTaskDataExtractor(dm.SourceExtractor, CVATDataExtractorMixin): def __init__(self, task_data, include_images=False, format_type=None, dimension=DimensionType.DIM_2D): - super().__init__() + super().__init__(media_type=dm.Image if dimension == DimensionType.DIM_2D else PointCloud) self._categories = self._load_categories(task_data.meta['task']['labels']) self._user = self._load_user_info(task_data.meta['task']) if dimension == DimensionType.DIM_3D else {} self._dimension = dimension @@ -1148,14 +1143,14 @@ def _make_image(i, **kwargs): loader = lambda _: frame_provider.get_frame(i, quality=frame_provider.Quality.ORIGINAL, out_type=frame_provider.Type.NUMPY_ARRAY)[0] - return Image(loader=loader, **kwargs) + return dm.Image(data=loader, **kwargs) else: # for images use encoded data to avoid recoding def _make_image(i, **kwargs): loader = lambda _: frame_provider.get_frame(i, quality=frame_provider.Quality.ORIGINAL, out_type=frame_provider.Type.BUFFER)[0].getvalue() - return ByteImage(data=loader, **kwargs) + return dm.ByteImage(data=loader, **kwargs) for frame_data in task_data.group_by_frame(include_empty=True): image_args = { @@ -1168,13 +1163,13 @@ def _make_image(i, **kwargs): elif include_images: dm_image = _make_image(frame_data.idx, **image_args) else: - dm_image = Image(**image_args) + dm_image = dm.Image(**image_args) dm_anno = self._read_cvat_anno(frame_data, task_data.meta['task']['labels']) if dimension == DimensionType.DIM_2D: - dm_item = datum_extractor.DatasetItem( + dm_item = dm.DatasetItem( id=osp.splitext(frame_data.name)[0], - annotations=dm_anno, image=dm_image, + annotations=dm_anno, media=dm_image, attributes={'frame': frame_data.frame }) elif dimension == DimensionType.DIM_3D: @@ -1188,9 +1183,9 @@ def _make_image(i, **kwargs): attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) attributes["track_id"] = -1 - dm_item = datum_extractor.DatasetItem( + dm_item = dm.DatasetItem( id=osp.splitext(osp.split(frame_data.name)[-1])[0], - annotations=dm_anno, point_cloud=dm_image[0], related_images=dm_image[1], + annotations=dm_anno, media=PointCloud(dm_image[0]), related_images=dm_image[1], attributes=attributes ) @@ -1200,24 +1195,24 @@ def _make_image(i, **kwargs): def _read_cvat_anno(self, cvat_frame_anno: TaskData.Frame, labels: list): categories = self.categories() - label_cat = categories[datum_annotation.AnnotationType.label] - def map_label(name): return label_cat.find(name)[0] + label_cat = categories[dm.AnnotationType.label] + def map_label(name, parent=''): return label_cat.find(name, parent)[0] label_attrs = { - label['name']: label['attributes'] + label.get('parent', '') + label['name']: label['attributes'] for _, label in labels } return convert_cvat_anno_to_dm(cvat_frame_anno, label_attrs, map_label, self._format_type, self._dimension) -class CVATProjectDataExtractor(datum_extractor.Extractor, CVATDataExtractorMixin): +class CVATProjectDataExtractor(dm.Extractor, CVATDataExtractorMixin): def __init__(self, project_data: ProjectData, include_images: bool = False, format_type: str = None, dimension: DimensionType = DimensionType.DIM_2D): - super().__init__() + super().__init__(media_type=dm.Image if dimension == DimensionType.DIM_2D else PointCloud) self._categories = self._load_categories(project_data.meta['project']['labels']) self._user = self._load_user_info(project_data.meta['project']) if dimension == DimensionType.DIM_3D else {} self._dimension = dimension self._format_type = format_type - dm_items: List[datum_extractor.DatasetItem] = [] + dm_items: List[dm.DatasetItem] = [] ext_per_task: Dict[int, str] = {} image_maker_per_task: Dict[int, Callable] = {} @@ -1251,7 +1246,7 @@ def _make_image(i, **kwargs): loader = lambda _: frame_provider.get_frame(i, quality=frame_provider.Quality.ORIGINAL, out_type=frame_provider.Type.NUMPY_ARRAY)[0] - return Image(loader=loader, **kwargs) + return dm.Image(data=loader, **kwargs) return _make_image else: # for images use encoded data to avoid recoding @@ -1261,7 +1256,7 @@ def _make_image(i, **kwargs): loader = lambda _: frame_provider.get_frame(i, quality=frame_provider.Quality.ORIGINAL, out_type=frame_provider.Type.BUFFER)[0].getvalue() - return ByteImage(data=loader, **kwargs) + return dm.ByteImage(data=loader, **kwargs) return _make_image image_maker_per_task[task.id] = image_maker_factory(task) @@ -1275,12 +1270,12 @@ def _make_image(i, **kwargs): elif include_images: dm_image = image_maker_per_task[frame_data.task_id](frame_data.idx, **image_args) else: - dm_image = Image(**image_args) + dm_image = dm.Image(**image_args) dm_anno = self._read_cvat_anno(frame_data, project_data.meta['project']['labels']) if self._dimension == DimensionType.DIM_2D: - dm_item = datum_extractor.DatasetItem( + dm_item = dm.DatasetItem( id=osp.splitext(frame_data.name)[0], - annotations=dm_anno, image=dm_image, + annotations=dm_anno, media=dm_image, subset=frame_data.subset, attributes={'frame': frame_data.frame} ) @@ -1295,9 +1290,9 @@ def _make_image(i, **kwargs): attributes["labels"].append({"label_id": idx, "name": label["name"], "color": label["color"], "type": label["type"]}) attributes["track_id"] = -1 - dm_item = datum_extractor.DatasetItem( + dm_item = dm.DatasetItem( id=osp.splitext(osp.split(frame_data.name)[-1])[0], - annotations=dm_anno, point_cloud=dm_image[0], related_images=dm_image[1], + annotations=dm_anno, media=PointCloud(dm_image[0]), related_images=dm_image[1], attributes=attributes, subset=frame_data.subset ) dm_items.append(dm_item) @@ -1348,13 +1343,13 @@ def get_defaulted_subset(subset: str, subsets: List[str]) -> str: if subset: return subset else: - if datum_extractor.DEFAULT_SUBSET_NAME not in subsets: - return datum_extractor.DEFAULT_SUBSET_NAME + if dm.DEFAULT_SUBSET_NAME not in subsets: + return dm.DEFAULT_SUBSET_NAME else: i = 1 while i < sys.maxsize: - if f'{datum_extractor.DEFAULT_SUBSET_NAME}_{i}' not in subsets: - return f'{datum_extractor.DEFAULT_SUBSET_NAME}_{i}' + if f'{dm.DEFAULT_SUBSET_NAME}_{i}' not in subsets: + return f'{dm.DEFAULT_SUBSET_NAME}_{i}' i += 1 raise Exception('Cannot find default name for subset') @@ -1385,7 +1380,7 @@ def convert_attrs(label, cvat_attrs): anno_label = map_label(tag_obj.label) anno_attr = convert_attrs(tag_obj.label, tag_obj.attributes) - anno = datum_annotation.Label(label=anno_label, + anno = dm.Label(label=anno_label, attributes=anno_attr, group=anno_group) item_anno.append(anno) @@ -1408,7 +1403,7 @@ def convert_attrs(label, cvat_attrs): anno_points = shape_obj.points if shape_obj.type == ShapeType.POINTS: - anno = datum_annotation.Points(anno_points, + anno = dm.Points(anno_points, label=anno_label, attributes=anno_attr, group=anno_group, z_order=shape_obj.z_order) elif shape_obj.type == ShapeType.ELLIPSE: @@ -1424,16 +1419,16 @@ def convert_attrs(label, cvat_attrs): "attributes": anno_attr, }), cvat_frame_anno.height, cvat_frame_anno.width) elif shape_obj.type == ShapeType.POLYLINE: - anno = datum_annotation.PolyLine(anno_points, + anno = dm.PolyLine(anno_points, label=anno_label, attributes=anno_attr, group=anno_group, z_order=shape_obj.z_order) elif shape_obj.type == ShapeType.POLYGON: - anno = datum_annotation.Polygon(anno_points, + anno = dm.Polygon(anno_points, label=anno_label, attributes=anno_attr, group=anno_group, z_order=shape_obj.z_order) elif shape_obj.type == ShapeType.RECTANGLE: x0, y0, x1, y1 = anno_points - anno = datum_annotation.Bbox(x0, y0, x1 - x0, y1 - y0, + anno = dm.Bbox(x0, y0, x1 - x0, y1 - y0, label=anno_label, attributes=anno_attr, group=anno_group, z_order=shape_obj.z_order) elif shape_obj.type == ShapeType.CUBOID: @@ -1443,27 +1438,30 @@ def convert_attrs(label, cvat_attrs): else: anno_id = index position, rotation, scale = anno_points[0:3], anno_points[3:6], anno_points[6:9] - anno = datum_annotation.Cuboid3d( + anno = dm.Cuboid3d( id=anno_id, position=position, rotation=rotation, scale=scale, label=anno_label, attributes=anno_attr, group=anno_group ) else: continue elif shape_obj.type == ShapeType.SKELETON: - points = [] - vis = [] + elements = [] for element in shape_obj.elements: - points.extend(element.points) - element_vis = datum_annotation.Points.Visibility.visible + element_attr = convert_attrs(shape_obj.label + element.label, element.attributes) + + if hasattr(element, 'track_id'): + element_attr['track_id'] = element.track_id + element_attr['keyframe'] = element.keyframe + element_vis = dm.Points.Visibility.visible if element.outside: - element_vis = datum_annotation.Points.Visibility.absent + element_vis = dm.Points.Visibility.absent elif element.occluded: - element_vis = datum_annotation.Points.Visibility.hidden - vis.append(element_vis) + element_vis = dm.Points.Visibility.hidden + elements.append(dm.Points(element.points, [element_vis], + label=map_label(element.label, shape_obj.label), attributes=element_attr)) - anno = datum_annotation.Points(points, vis, - label=anno_label, attributes=anno_attr, group=anno_group, - z_order=shape_obj.z_order) + anno = dm.Skeleton(elements, label=anno_label, + attributes=anno_attr, group=anno_group, z_order=shape_obj.z_order) else: raise Exception("Unknown shape type '%s'" % shape_obj.type) @@ -1480,9 +1478,9 @@ def match_dm_item(item, task_data, root_hint=None): if frame_number is None: frame_number = task_data.match_frame(item.id, root_hint, path_has_ext=False) if frame_number is None: - frame_number = cast(item.attributes.get('frame', item.id), int) + frame_number = dm.util.cast(item.attributes.get('frame', item.id), int) if frame_number is None and is_video: - frame_number = cast(osp.basename(item.id)[len('frame_'):], int) + frame_number = dm.util.cast(osp.basename(item.id)[len('frame_'):], int) if not frame_number in task_data.frame_info: raise CvatImportError("Could not match item id: " @@ -1505,7 +1503,7 @@ def find_dataset_root(dm_dataset, instance_data: Union[TaskData, ProjectData]): prefix = prefix[:-1] return prefix -def import_dm_annotations(dm_dataset: Dataset, instance_data: Union[TaskData, ProjectData]): +def import_dm_annotations(dm_dataset: dm.Dataset, instance_data: Union[TaskData, ProjectData]): if len(dm_dataset) == 0: return @@ -1518,15 +1516,15 @@ def import_dm_annotations(dm_dataset: Dataset, instance_data: Union[TaskData, Pr return shapes = { - datum_annotation.AnnotationType.bbox: ShapeType.RECTANGLE, - datum_annotation.AnnotationType.polygon: ShapeType.POLYGON, - datum_annotation.AnnotationType.polyline: ShapeType.POLYLINE, - datum_annotation.AnnotationType.points: ShapeType.POINTS, - datum_annotation.AnnotationType.cuboid_3d: ShapeType.CUBOID + dm.AnnotationType.bbox: ShapeType.RECTANGLE, + dm.AnnotationType.polygon: ShapeType.POLYGON, + dm.AnnotationType.polyline: ShapeType.POLYLINE, + dm.AnnotationType.points: ShapeType.POINTS, + dm.AnnotationType.cuboid_3d: ShapeType.CUBOID, + dm.AnnotationType.skeleton: ShapeType.SKELETON } - label_cat = dm_dataset.categories()[datum_annotation.AnnotationType.label] - point_cat = dm_dataset.categories().get(datum_annotation.AnnotationType.points) + label_cat = dm_dataset.categories()[dm.AnnotationType.label] root_hint = find_dataset_root(dm_dataset, instance_data) @@ -1562,55 +1560,59 @@ def import_dm_annotations(dm_dataset: Dataset, instance_data: Union[TaskData, Pr for n, v in ann.attributes.items() ] + points = [] if ann.type in shapes: - if ann.type == datum_annotation.AnnotationType.cuboid_3d: - try: - ann.points = [*ann.position,*ann.rotation,*ann.scale,0,0,0,0,0,0,0] - except Exception: - ann.points = ann.points - ann.z_order = 0 + points = [] + if ann.type == dm.AnnotationType.cuboid_3d: + points = [*ann.position, *ann.rotation, *ann.scale, 0, 0, 0, 0, 0, 0, 0] + elif ann.type != dm.AnnotationType.skeleton: + points = ann.points # Use safe casting to bool instead of plain reading # because in some formats return type can be different # from bool / None # https://github.com/openvinotoolkit/datumaro/issues/719 - occluded = cast(ann.attributes.pop('occluded', None), bool) is True - keyframe = cast(ann.attributes.get('keyframe', None), bool) is True - outside = cast(ann.attributes.pop('outside', None), bool) is True + occluded = dm.util.cast(ann.attributes.pop('occluded', None), bool) is True + keyframe = dm.util.cast(ann.attributes.get('keyframe', None), bool) is True + outside = dm.util.cast(ann.attributes.pop('outside', None), bool) is True track_id = ann.attributes.pop('track_id', None) source = ann.attributes.pop('source').lower() \ if ann.attributes.get('source', '').lower() in {'auto', 'manual'} else 'manual' shape_type = shapes[ann.type] - elements = [] - if point_cat and shape_type == ShapeType.POINTS: - labels = point_cat.items[ann.label].labels - shape_type = ShapeType.SKELETON - for i in range(len(ann.points) // 2): - label = None - if i < len(labels): - label = labels[i] - elements.append(instance_data.LabeledShape( - type=ShapeType.POINTS, - frame=frame_number, - points=ann.points[2 * i : 2 * i + 2], - label=label, - occluded=ann.visibility[i] == datum_annotation.Points.Visibility.hidden, - source=source, - attributes=[], - outside=ann.visibility[i] == datum_annotation.Points.Visibility.absent, - )) - - if track_id is None or dm_dataset.format != 'cvat' : + elements = [] + if ann.type == dm.AnnotationType.skeleton: + for element in ann.elements: + element_attributes = [ + instance_data.Attribute(name=n, value=str(v)) + for n, v in element.attributes.items() + ] + element_occluded = element.visibility[0] == dm.Points.Visibility.hidden + element_outside = element.visibility[0] == dm.Points.Visibility.absent + element_source = element.attributes.pop('source').lower() \ + if element.attributes.get('source', '').lower() in {'auto', 'manual'} else 'manual' + elements.append(instance_data.LabeledShape( + type=shapes[element.type], + frame=frame_number, + points=element.points, + label=label_cat.items[element.label].name, + occluded=element_occluded, + z_order=ann.z_order, + group=group_map.get(ann.group, 0), + source=element_source, + attributes=element_attributes, + elements=[], + outside=element_outside, + )) instance_data.add_shape(instance_data.LabeledShape( type=shape_type, frame=frame_number, - points=ann.points, + points=points, label=label_cat.items[ann.label].name, occluded=occluded, - z_order=ann.z_order, + z_order=ann.z_order if ann.type != dm.AnnotationType.cuboid_3d else 0, group=group_map.get(ann.group, 0), source=source, attributes=attributes, @@ -1619,29 +1621,63 @@ def import_dm_annotations(dm_dataset: Dataset, instance_data: Union[TaskData, Pr continue if keyframe or outside: + if track_id not in tracks: + tracks[track_id] = { + 'label': label_cat.items[ann.label].name, + 'group': group_map.get(ann.group, 0), + 'source': source, + 'shapes': [], + 'elements':{}, + } + track = instance_data.TrackedShape( type=shapes[ann.type], frame=frame_number, occluded=occluded, outside=outside, keyframe=keyframe, - points=ann.points, - z_order=ann.z_order, + points=points, + z_order=ann.z_order if ann.type != dm.AnnotationType.cuboid_3d else 0, source=source, attributes=attributes, ) - if track_id not in tracks: - tracks[track_id] = instance_data.Track( - label=label_cat.items[ann.label].name, - group=group_map.get(ann.group, 0), - source=source, - shapes=[], - ) - - tracks[track_id].shapes.append(track) - - elif ann.type == datum_annotation.AnnotationType.label: + tracks[track_id]['shapes'].append(track) + + if ann.type == dm.AnnotationType.skeleton: + for element in ann.elements: + element_keyframe = dm.util.cast(element.attributes.get('keyframe', None), bool) is True + element_outside = dm.util.cast(element.attributes.pop('outside', None), bool) is True + if not element_keyframe and not element_outside: + continue + + if element.label not in tracks[track_id]['elements']: + tracks[track_id]['elements'][element.label] = instance_data.Track( + label=label_cat.items[element.label].name, + group=0, + source=source, + shapes=[], + ) + element_attributes = [ + instance_data.Attribute(name=n, value=str(v)) + for n, v in element.attributes.items() + ] + element_occluded = dm.util.cast(element.attributes.pop('occluded', None), bool) is True + element_source = element.attributes.pop('source').lower() \ + if element.attributes.get('source', '').lower() in {'auto', 'manual'} else 'manual' + tracks[track_id]['elements'][element.label].shapes.append(instance_data.TrackedShape( + type=shapes[element.type], + frame=frame_number, + occluded=element_occluded, + outside=element_outside, + keyframe=keyframe, + points=element.points, + z_order=element.z_order, + source=element_source, + attributes=element_attributes, + )) + + elif ann.type == dm.AnnotationType.label: instance_data.add_tag(instance_data.Tag( frame=frame_number, label=label_cat.items[ann.label].name, @@ -1654,13 +1690,14 @@ def import_dm_annotations(dm_dataset: Dataset, instance_data: Union[TaskData, Pr "#{} ({}): {}".format(item.id, idx, ann.type.name, e)) from e for track in tracks.values(): - instance_data.add_track(track) + track['elements'] = list(track['elements'].values()) + instance_data.add_track(instance_data.Track(**track)) -def import_labels_to_project(project_annotation, dataset: Dataset): +def import_labels_to_project(project_annotation, dataset: dm.Dataset): labels = [] label_colors = [] - for label in dataset.categories()[datum_annotation.AnnotationType.label].items: + for label in dataset.categories()[dm.AnnotationType.label].items: db_label = Label( name=label.name, color=get_label_color(label.name, label_colors), @@ -1670,11 +1707,11 @@ def import_labels_to_project(project_annotation, dataset: Dataset): label_colors.append(db_label.color) project_annotation.add_labels(labels) -def load_dataset_data(project_annotation, dataset: Dataset, project_data): +def load_dataset_data(project_annotation, dataset: dm.Dataset, project_data): if not project_annotation.db_project.label_set.count(): import_labels_to_project(project_annotation, dataset) else: - for label in dataset.categories()[datum_annotation.AnnotationType.label].items: + for label in dataset.categories()[dm.AnnotationType.label].items: if not project_annotation.db_project.label_set.filter(name=label.name).exists(): raise CvatImportError(f'Target project does not have label with name "{label.name}"') for subset_id, subset in enumerate(dataset.subsets().values()): diff --git a/cvat/apps/dataset_manager/formats/cvat.py b/cvat/apps/dataset_manager/formats/cvat.py index 97bd98a31325..ede5b67c1ae8 100644 --- a/cvat/apps/dataset_manager/formats/cvat.py +++ b/cvat/apps/dataset_manager/formats/cvat.py @@ -1,4 +1,5 @@ # Copyright (C) 2018-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -13,7 +14,7 @@ from datumaro.components.annotation import (AnnotationType, Bbox, Label, LabelCategories, Points, Polygon, - PolyLine) + PolyLine, Skeleton) from datumaro.components.dataset import Dataset, DatasetItem from datumaro.components.extractor import (DEFAULT_SUBSET_NAME, Extractor, Importer) @@ -118,23 +119,34 @@ def _parse(cls, path): items = OrderedDict() track = None + track_element = None + track_shapes = None shape = None + shape_element = None tag = None attributes = None + element_attributes = None image = None subset = None for ev, el in context: if ev == 'start': if el.tag == 'track': - frame_size = tasks_info[int(el.attrib.get('task_id'))]['frame_size'] if el.attrib.get('task_id') else tuple(tasks_info.values())[0]['frame_size'] - track = { - 'id': el.attrib['id'], - 'label': el.attrib.get('label'), - 'group': int(el.attrib.get('group_id', 0)), - 'height': frame_size[0], - 'width': frame_size[1], - } - subset = el.attrib.get('subset') + if track: + track_element = { + 'id': el.attrib['id'], + 'label': el.attrib.get('label'), + } + else: + frame_size = tasks_info[int(el.attrib.get('task_id'))]['frame_size'] if el.attrib.get('task_id') else tuple(tasks_info.values())[0]['frame_size'] + track = { + 'id': el.attrib['id'], + 'label': el.attrib.get('label'), + 'group': int(el.attrib.get('group_id', 0)), + 'height': frame_size[0], + 'width': frame_size[1], + } + subset = el.attrib.get('subset') + track_shapes = {} elif el.tag == 'image': image = { 'name': el.attrib.get('name'), @@ -144,16 +156,28 @@ def _parse(cls, path): } subset = el.attrib.get('subset') elif el.tag in cls._SUPPORTED_SHAPES and (track or image): - attributes = {} - shape = { - 'type': None, - 'attributes': attributes, - } - if track: - shape.update(track) - shape['track_id'] = int(track['id']) - if image: - shape.update(image) + if shape and shape['type'] == 'skeleton': + element_attributes = {} + shape_element = { + 'type': 'rectangle' if el.tag == 'box' else el.tag, + 'attributes': element_attributes, + } + shape_element.update(image) + else: + attributes = {} + shape = { + 'type': 'rectangle' if el.tag == 'box' else el.tag, + 'attributes': attributes, + } + shape['elements'] = [] + if track_element: + shape.update(track_element) + shape['track_id'] = int(track_element['id']) + elif track: + shape.update(track) + shape['track_id'] = int(track['id']) + if image: + shape.update(image) elif el.tag == 'tag' and image: attributes = {} tag = { @@ -164,7 +188,19 @@ def _parse(cls, path): } subset = el.attrib.get('subset') elif ev == 'end': - if el.tag == 'attribute' and attributes is not None: + if el.tag == 'attribute' and element_attributes is not None and shape_element is not None: + attr_value = el.text or '' + attr_type = attribute_types.get(el.attrib['name']) + if el.text in ['true', 'false']: + attr_value = attr_value == 'true' + elif attr_type is not None and attr_type != 'text': + try: + attr_value = float(attr_value) + except ValueError: + pass + element_attributes[el.attrib['name']] = attr_value + + if el.tag == 'attribute' and attributes is not None and shape_element is None: attr_value = el.text or '' attr_type = attribute_types.get(el.attrib['name']) if el.text in ['true', 'false']: @@ -175,6 +211,37 @@ def _parse(cls, path): except ValueError: pass attributes[el.attrib['name']] = attr_value + + elif el.tag in cls._SUPPORTED_SHAPES and shape["type"] == "skeleton" and el.tag != "skeleton": + shape_element['label'] = el.attrib.get('label') + shape_element['group'] = int(el.attrib.get('group_id', 0)) + + shape_element['type'] = el.tag + shape_element['z_order'] = int(el.attrib.get('z_order', 0)) + + if el.tag == 'box': + shape_element['points'] = list(map(float, [ + el.attrib['xtl'], el.attrib['ytl'], + el.attrib['xbr'], el.attrib['ybr'], + ])) + else: + shape_element['points'] = [] + for pair in el.attrib['points'].split(';'): + shape_element['points'].extend(map(float, pair.split(','))) + + if el.tag == 'points' and el.attrib.get('occluded') == '1': + shape_element['visibility'] = [Points.Visibility.hidden] * (len(shape_element['points']) // 2) + else: + shape_element['occluded'] = (el.attrib.get('occluded') == '1') + + if el.tag == 'points' and el.attrib.get('outside') == '1': + shape_element['visibility'] = [Points.Visibility.absent] * (len(shape_element['points']) // 2) + else: + shape_element['outside'] = (el.attrib.get('outside') == '1') + + shape['elements'].append(shape_element) + shape_element = None + elif el.tag in cls._SUPPORTED_SHAPES: if track is not None: shape['frame'] = el.attrib['frame'] @@ -193,15 +260,22 @@ def _parse(cls, path): el.attrib['xtl'], el.attrib['ytl'], el.attrib['xbr'], el.attrib['ybr'], ])) + elif el.tag == 'skeleton': + shape['points'] = [] else: shape['points'] = [] for pair in el.attrib['points'].split(';'): shape['points'].extend(map(float, pair.split(','))) + if track_element: + track_shapes[shape['frame']]['elements'].append(shape) + elif track: + track_shapes[shape['frame']] = shape + else: + frame_desc = items.get((subset, shape['frame']), {'annotations': []}) + frame_desc['annotations'].append( + cls._parse_shape_ann(shape, categories)) + items[(subset, shape['frame'])] = frame_desc - frame_desc = items.get((subset, shape['frame']), {'annotations': []}) - frame_desc['annotations'].append( - cls._parse_shape_ann(shape, categories)) - items[(subset, shape['frame'])] = frame_desc shape = None elif el.tag == 'tag': @@ -211,7 +285,15 @@ def _parse(cls, path): items[(subset, tag['frame'])] = frame_desc tag = None elif el.tag == 'track': - track = None + if track_element: + track_element = None + else: + for track_shape in track_shapes.values(): + frame_desc = items.get((subset, track_shape['frame']), {'annotations': []}) + frame_desc['annotations'].append( + cls._parse_shape_ann(track_shape, categories)) + items[(subset, track_shape['frame'])] = frame_desc + track = None elif el.tag == 'image': frame_desc = items.get((subset, image['frame']), {'annotations': []}) frame_desc.update({ @@ -376,7 +458,8 @@ def _parse_shape_ann(cls, ann, categories): id=ann_id, attributes=attributes, group=group) elif ann_type == 'points': - return Points(points, label=label_id, z_order=z_order, + visibility = ann.get('visibility', None) + return Points(points, visibility, label=label_id, z_order=z_order, id=ann_id, attributes=attributes, group=group) elif ann_type == 'box': @@ -385,6 +468,14 @@ def _parse_shape_ann(cls, ann, categories): return Bbox(x, y, w, h, label=label_id, z_order=z_order, id=ann_id, attributes=attributes, group=group) + elif ann_type == 'skeleton': + elements = [] + for element in ann.get('elements', []): + elements.append(cls._parse_shape_ann(element, categories)) + + return Skeleton(elements, label=label_id, z_order=z_order, + id=ann_id, attributes=attributes, group=group) + else: raise NotImplementedError("Unknown annotation type '%s'" % ann_type) @@ -409,7 +500,7 @@ def _load_items(self, parsed, image_items): di.subset = subset or DEFAULT_SUBSET_NAME di.annotations = item_desc.get('annotations') di.attributes = {'frame': int(frame_id)} - di.image = image if isinstance(image, Image) else di.image + di.media = image if isinstance(image, Image) else di.media image_items[(subset, osp.splitext(name)[0])] = di return image_items @@ -962,7 +1053,10 @@ def dump_track(idx, track): elements=[], ) for element in shape.elements] } - if isinstance(annotations, ProjectData): track['task_id'] = shape.task_id + if isinstance(annotations, ProjectData): + track['task_id'] = shape.task_id + for element in track['elements']: + element.task_id = shape.task_id dump_track(counter, annotations.Track(**track)) counter += 1 diff --git a/cvat/apps/dataset_manager/formats/icdar.py b/cvat/apps/dataset_manager/formats/icdar.py index a46173f4c871..013ccf0a3ad2 100644 --- a/cvat/apps/dataset_manager/formats/icdar.py +++ b/cvat/apps/dataset_manager/formats/icdar.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -90,7 +91,7 @@ def _import(src_file, instance_data, load_data_callback=None): with TemporaryDirectory() as tmp_dir: zipfile.ZipFile(src_file).extractall(tmp_dir) dataset = Dataset.import_from(tmp_dir, 'icdar_word_recognition', env=dm_env) - dataset.transform(CaptionToLabel, 'icdar') + dataset.transform(CaptionToLabel, label='icdar') if load_data_callback is not None: load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) @@ -110,7 +111,7 @@ def _import(src_file, instance_data, load_data_callback=None): zipfile.ZipFile(src_file).extractall(tmp_dir) dataset = Dataset.import_from(tmp_dir, 'icdar_text_localization', env=dm_env) - dataset.transform(AddLabelToAnns, 'icdar') + dataset.transform(AddLabelToAnns, label='icdar') if load_data_callback is not None: load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) @@ -133,7 +134,7 @@ def _import(src_file, instance_data, load_data_callback=None): with TemporaryDirectory() as tmp_dir: zipfile.ZipFile(src_file).extractall(tmp_dir) dataset = Dataset.import_from(tmp_dir, 'icdar_text_segmentation', env=dm_env) - dataset.transform(AddLabelToAnns, 'icdar') + dataset.transform(AddLabelToAnns, label='icdar') dataset.transform('masks_to_polygons') if load_data_callback is not None: load_data_callback(dataset, instance_data) diff --git a/cvat/apps/dataset_manager/formats/market1501.py b/cvat/apps/dataset_manager/formats/market1501.py index f4c305d31811..b5902002d46c 100644 --- a/cvat/apps/dataset_manager/formats/market1501.py +++ b/cvat/apps/dataset_manager/formats/market1501.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -65,7 +66,7 @@ def _export(dst_file, instance_data, save_images=False): dataset = Dataset.from_extractors(GetCVATDataExtractor( instance_data, include_images=save_images), env=dm_env) with TemporaryDirectory() as temp_dir: - dataset.transform(LabelAttrToAttr, 'market-1501') + dataset.transform(LabelAttrToAttr, label='market-1501') dataset.export(temp_dir, 'market1501', save_images=save_images) make_zip_archive(temp_dir, dst_file) @@ -75,7 +76,7 @@ def _import(src_file, instance_data, load_data_callback=None): zipfile.ZipFile(src_file).extractall(tmp_dir) dataset = Dataset.import_from(tmp_dir, 'market1501', env=dm_env) - dataset.transform(AttrToLabelAttr, 'market-1501') + dataset.transform(AttrToLabelAttr, label='market-1501') if load_data_callback is not None: load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) diff --git a/cvat/apps/dataset_manager/formats/transformations.py b/cvat/apps/dataset_manager/formats/transformations.py index 49de6caff6bc..ecce6474a65b 100644 --- a/cvat/apps/dataset_manager/formats/transformations.py +++ b/cvat/apps/dataset_manager/formats/transformations.py @@ -9,7 +9,7 @@ from pycocotools import mask as mask_utils from datumaro.components.extractor import ItemTransform -import datumaro.components.annotation as datum_annotation +import datumaro.components.annotation as dm class RotatedBoxesToPolygons(ItemTransform): def _rotate_point(self, p, angle, cx, cy): @@ -20,7 +20,7 @@ def _rotate_point(self, p, angle, cx, cy): def transform_item(self, item): annotations = item.annotations[:] - anns = [p for p in annotations if p.type == datum_annotation.AnnotationType.bbox and p.attributes['rotation']] + anns = [p for p in annotations if p.type == dm.AnnotationType.bbox and p.attributes['rotation']] for ann in anns: rotation = math.radians(ann.attributes['rotation']) x0, y0, x1, y1 = ann.points @@ -30,7 +30,7 @@ def transform_item(self, item): )) annotations.remove(ann) - annotations.append(datum_annotation.Polygon(anno_points, + annotations.append(dm.Polygon(anno_points, label=ann.label, attributes=ann.attributes, group=ann.group, z_order=ann.z_order)) @@ -48,5 +48,5 @@ def convert_ellipse(ellipse, img_h, img_w): mat = np.zeros((img_h, img_w), dtype=np.uint8) cv2.ellipse(mat, center, axis, angle, 0, 360, 255, thickness=-1) rle = mask_utils.encode(np.asfortranarray(mat)) - return datum_annotation.RleMask(rle=rle, label=ellipse.label, z_order=ellipse.z_order, + return dm.RleMask(rle=rle, label=ellipse.label, z_order=ellipse.z_order, attributes=ellipse.attributes, group=ellipse.group) diff --git a/cvat/apps/dataset_manager/formats/vggface2.py b/cvat/apps/dataset_manager/formats/vggface2.py index fc699050caf9..e5f3eca1adfc 100644 --- a/cvat/apps/dataset_manager/formats/vggface2.py +++ b/cvat/apps/dataset_manager/formats/vggface2.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -29,7 +30,7 @@ def _import(src_file, instance_data, load_data_callback=None): zipfile.ZipFile(src_file).extractall(tmp_dir) dataset = Dataset.import_from(tmp_dir, 'vgg_face2', env=dm_env) - dataset.transform('rename', r"|([^/]+/)?(.+)|\2|") + dataset.transform('rename', regex=r"|([^/]+/)?(.+)|\2|") if load_data_callback is not None: load_data_callback(dataset, instance_data) import_dm_annotations(dataset, instance_data) diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index f76274fce057..ea486b68fddf 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -1,5 +1,6 @@ # Copyright (C) 2019-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -426,15 +427,21 @@ def _init_shapes_from_db(self): ) shapes = {} + elements = {} for db_shape in db_shapes: self._extend_attributes(db_shape.labeledshapeattributeval_set, self.db_attributes[db_shape.label_id]["all"].values()) - db_shape.elements = [] + if db_shape.parent is None: shapes[db_shape.id] = db_shape else: - shapes[db_shape.parent].elements.append(db_shape) + if db_shape.parent not in elements: + elements[db_shape.parent] = [] + elements[db_shape.parent].append(db_shape) + + for shape_id, shape_elements in elements.items(): + shapes[shape_id].elements = shape_elements serializer = serializers.LabeledShapeSerializer(list(shapes.values()), many=True) self.ir_data.shapes = serializer.data @@ -493,6 +500,7 @@ def _init_tracks_from_db(self): ) tracks = {} + elements = {} for db_track in db_tracks: db_track["trackedshape_set"] = _merge_table_rows(db_track["trackedshape_set"], { 'trackedshapeattributeval_set': [ @@ -518,11 +526,15 @@ def _init_tracks_from_db(self): self._extend_attributes(db_shape["trackedshapeattributeval_set"], default_attribute_values) default_attribute_values = db_shape["trackedshapeattributeval_set"] - db_track.elements = [] if db_track.parent is None: tracks[db_track.id] = db_track else: - tracks[db_track.parent].elements.append(db_track) + if db_track.parent not in elements: + elements[db_track.parent] = [] + elements[db_track.parent].append(db_track) + + for track_id, track_elements in elements.items(): + tracks[track_id].elements = track_elements serializer = serializers.LabeledTrackSerializer(list(tracks.values()), many=True) self.ir_data.tracks = serializer.data diff --git a/cvat/apps/dataset_manager/tests/assets/annotations.json b/cvat/apps/dataset_manager/tests/assets/annotations.json index 4ec55a94cd7d..e92ed404d6a8 100644 --- a/cvat/apps/dataset_manager/tests/assets/annotations.json +++ b/cvat/apps/dataset_manager/tests/assets/annotations.json @@ -533,15 +533,7 @@ }, "Market-1501 1.0": { "version": 0, - "tags": [ - { - "frame": 1, - "label_id": null, - "group": 0, - "source": "manual", - "attributes": [] - } - ], + "tags": [], "shapes": [], "tracks": [] }, @@ -716,43 +708,6 @@ ], "tracks": [] }, - "WiderFace 1.0": { - "version": 0, - "tags": [ - { - "frame": 0, - "label_id": null, - "group": 0, - "source": "manual", - "attributes": [] - } - ], - "shapes": [ - { - "type": "rectangle", - "occluded": false, - "z_order": 0, - "points": [7.55, 9.75, 16.44, 15.85], - "frame": 0, - "label_id": null, - "group": 0, - "source": "manual", - "attributes": [] - }, - { - "type": "rectangle", - "occluded": true, - "z_order": 0, - "points": [3.55, 27.75, 11.33, 33.71], - "frame": 0, - "label_id": null, - "group": 0, - "source": "manual", - "attributes": [] - } - ], - "tracks": [] - }, "VGGFace2 1.0": { "version": 0, "tags": [], @@ -1093,7 +1048,6 @@ "points": [66.45, 147.08, 182.16, 204.56], "frame": 0, "outside": false, - "outside": true, "attributes": [] }, { @@ -1223,50 +1177,6 @@ } ] }, - "CVAT for video 1.1 polygon": { - "version": 0, - "tags": [], - "shapes": [], - "tracks": [ - { - "frame": 0, - "label_id": null, - "group": 1, - "source": "manual", - "shapes": [ - { - "type": "polygon", - "occluded": false, - "z_order": 0, - "points": [24.62, 13.01, 34.88, 20.03, 18.14, 18.08], - "frame": 0, - "outside": false, - "attributes": [] - }, - { - "type": "polygon", - "occluded": false, - "z_order": 0, - "points": [24.62, 13.01, 34.88, 20.03, 18.14, 18.08], - "frame": 1, - "outside": true, - "attributes": [] - }, - { - "type": "polygon", - "occluded": false, - "z_order": 0, - "points": [24.62, 13.01, 34.88, 20.03, 18.14, 18.08], - "frame": 2, - "outside": false, - "keyframe": true, - "attributes": [] - } - ], - "attributes": [] - } - ] - }, "CVAT for video 1.1 attributes in tracks": { "version": 0, "tags": [], diff --git a/cvat/apps/dataset_manager/tests/assets/tasks.json b/cvat/apps/dataset_manager/tests/assets/tasks.json index e301c54a2237..e5c4e140ecb7 100644 --- a/cvat/apps/dataset_manager/tests/assets/tasks.json +++ b/cvat/apps/dataset_manager/tests/assets/tasks.json @@ -336,7 +336,14 @@ "name": "skeleton", "color": "#2080c0", "type": "skeleton", - "attributes": [], + "attributes": [ + { + "name": "attr", + "mutable": false, + "input_type": "select", + "values": ["0", "1", "2"] + } + ], "sublabels": [ { "name": "1", @@ -353,14 +360,7 @@ { "name": "3", "color": "#479ffe", - "attributes": [ - { - "name": "attr", - "mutable": false, - "input_type": "select", - "values": ["0", "1", "2"] - } - ], + "attributes": [], "type": "points" } ], diff --git a/cvat/apps/dataset_manager/tests/test_formats.py b/cvat/apps/dataset_manager/tests/test_formats.py index 326a104b04e0..38ffe236d83c 100644 --- a/cvat/apps/dataset_manager/tests/test_formats.py +++ b/cvat/apps/dataset_manager/tests/test_formats.py @@ -1,5 +1,6 @@ # Copyright (C) 2020-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -617,6 +618,14 @@ def _generate_task_images(self, count, name="image", **image_params): images["image_quality"] = 75 return images + def _generate_task_images_by_names(self, names, **image_params): + images = { + f"client_files[{i}]": generate_image_file(f"{name}.jpg", **image_params) + for i, name in enumerate(names) + } + images["image_quality"] = 75 + return images + def _generate_task(self, images, annotation_format, **overrides): labels = [] if annotation_format in ["ICDAR Recognition 1.0", @@ -911,7 +920,10 @@ def test_can_import_annotations_for_image_with_dots_in_filename(self): for f in dm.views.get_import_formats(): format_name = f.DISPLAY_NAME - images = self._generate_task_images(3, "img0.0.0") + if format_name == "Market-1501 1.0": + images = self._generate_task_images_by_names(["img0.0.0_0", "1.0_c3s1_000000_00", "img0.0.0_1"]) + else: + images = self._generate_task_images(3, "img0.0.0") task = self._generate_task(images, format_name) self._generate_annotations(task, format_name) diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py index 9441ad623d11..b7661d8cdd0f 100644 --- a/cvat/apps/engine/backup.py +++ b/cvat/apps/engine/backup.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -31,7 +32,7 @@ from cvat.apps.engine.serializers import (AttributeSerializer, DataSerializer, LabeledDataSerializer, SegmentSerializer, SimpleJobSerializer, TaskReadSerializer, ProjectReadSerializer, ProjectFileSerializer, TaskFileSerializer) -from cvat.apps.engine.utils import av_scan_paths +from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job from cvat.apps.engine.models import ( StorageChoice, StorageMethodChoice, DataChoice, Task, Project, Location, CloudStorage as CloudStorageModel) @@ -39,7 +40,7 @@ from cvat.apps.dataset_manager.views import TASK_CACHE_TTL, PROJECT_CACHE_TTL, get_export_cache_dir, clear_export_cache, log_exception from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.engine.cloud_provider import ( - db_storage_to_storage_instance, validate_bucket_status + db_storage_to_storage_instance, import_from_cloud_storage, export_to_cloud_storage ) from cvat.apps.engine.location import StorageType, get_location_configuration @@ -172,34 +173,35 @@ def _update_attribute(attribute, label): source, dest = attribute.pop('spec_id'), 'name' attribute[dest] = label_mapping[label]['attributes'][source] - def _update_label(shape): + def _update_label(shape, parent_label=''): if 'label_id' in shape: - source, dest = shape.pop('label_id'), 'label' + source = shape.pop('label_id') + shape['label'] = label_mapping[source]['value'] elif 'label' in shape: - source, dest = shape.pop('label'), 'label_id' - shape[dest] = label_mapping[source]['value'] + source = parent_label + shape.pop('label') + shape['label_id'] = label_mapping[source]['value'] return source - def _prepare_shapes(shapes): + def _prepare_shapes(shapes, parent_label=''): for shape in shapes: - label = _update_label(shape) + label = _update_label(shape, parent_label) for attr in shape['attributes']: _update_attribute(attr, label) - _prepare_shapes(shape.get('elements', [])) + _prepare_shapes(shape.get('elements', []), label) self._prepare_meta(allowed_fields, shape) - def _prepare_tracks(tracks): + def _prepare_tracks(tracks, parent_label=''): for track in tracks: - label = _update_label(track) + label = _update_label(track, parent_label) for shape in track['shapes']: for attr in shape['attributes']: _update_attribute(attr, label) self._prepare_meta(allowed_fields, shape) - _prepare_tracks(track.get('elements', [])) + _prepare_tracks(track.get('elements', []), label) for attr in track['attributes']: _update_attribute(attr, label) @@ -426,7 +428,7 @@ def _create_labels(self, labels, db_task=None, db_project=None, parent_label=Non sublabels = label.pop('sublabels', []) db_label = models.Label.objects.create(**label_relation, parent=parent_label, **label) - label_mapping[label_name] = { + label_mapping[(parent_label.name if parent_label else '') + label_name] = { 'value': db_label.id, 'attributes': {}, } @@ -443,7 +445,7 @@ def _create_labels(self, labels, db_task=None, db_project=None, parent_label=Non attribute_serializer = AttributeSerializer(data=attribute) attribute_serializer.is_valid(raise_exception=True) db_attribute = attribute_serializer.save(label=db_label) - label_mapping[label_name]['attributes'][attribute_name] = db_attribute.id + label_mapping[(parent_label.name if parent_label else '') + label_name]['attributes'][attribute_name] = db_attribute.id return label_mapping @@ -787,11 +789,6 @@ def export(db_instance, request): return sendfile(request, file_path, attachment=True, attachment_filename=filename) elif location == Location.CLOUD_STORAGE: - - @validate_bucket_status - def _export_to_cloud_storage(storage, file_path, file_name): - storage.upload_file(file_path, file_name) - try: storage_id = location_conf['storage_id'] except KeyError: @@ -801,7 +798,7 @@ def _export_to_cloud_storage(storage, file_path, file_name): db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) storage = db_storage_to_storage_instance(db_storage) - _export_to_cloud_storage(storage, file_path, filename) + export_to_cloud_storage(storage, file_path, filename) return Response(status=status.HTTP_200_OK) else: raise NotImplementedError() @@ -825,6 +822,14 @@ def _export_to_cloud_storage(storage, file_path, file_name): result_ttl=ttl, failure_ttl=ttl) return Response(status=status.HTTP_202_ACCEPTED) + +def _download_file_from_bucket(db_storage, filename, key): + storage = db_storage_to_storage_instance(db_storage) + + data = import_from_cloud_storage(storage, key) + with open(filename, 'wb+') as f: + f.write(data.getbuffer()) + def _import(importer, request, rq_id, Serializer, file_field_name, location_conf, filename=None): queue = django_rq.get_queue("default") rq_job = queue.fetch_job(rq_id) @@ -832,6 +837,7 @@ def _import(importer, request, rq_id, Serializer, file_field_name, location_conf if not rq_job: org_id = getattr(request.iam_context['organization'], 'id', None) fd = None + dependent_job = None location = location_conf.get('location') if location == Location.LOCAL: @@ -844,14 +850,8 @@ def _import(importer, request, rq_id, Serializer, file_field_name, location_conf for chunk in payload_file.chunks(): f.write(chunk) else: - @validate_bucket_status - def _import_from_cloud_storage(storage, file_name): - return storage.download_fileobj(file_name) - file_name = request.query_params.get('filename') - assert file_name - - # download file from cloud storage + assert file_name, "The filename wasn't specified" try: storage_id = location_conf['storage_id'] except KeyError: @@ -859,13 +859,11 @@ def _import_from_cloud_storage(storage, file_name): 'Cloud storage location was selected for destination' ' but cloud storage id was not specified') db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) - storage = db_storage_to_storage_instance(db_storage) - - data = _import_from_cloud_storage(storage, file_name) - + key = filename fd, filename = mkstemp(prefix='cvat_', dir=settings.TMP_FILES_ROOT) - with open(filename, 'wb+') as f: - f.write(data.getbuffer()) + dependent_job = configure_dependent_job( + queue, rq_id, _download_file_from_bucket, + db_storage, filename, key) rq_job = queue.enqueue_call( func=importer, @@ -875,6 +873,7 @@ def _import_from_cloud_storage(storage, file_name): 'tmp_file': filename, 'tmp_file_descriptor': fd, }, + depends_on=dependent_job ) else: if rq_job.is_finished: @@ -883,12 +882,9 @@ def _import_from_cloud_storage(storage, file_name): os.remove(rq_job.meta['tmp_file']) rq_job.delete() return Response({'id': project_id}, status=status.HTTP_201_CREATED) - elif rq_job.is_failed: - if rq_job.meta['tmp_file_descriptor']: os.close(rq_job.meta['tmp_file_descriptor']) - os.remove(rq_job.meta['tmp_file']) - exc_info = str(rq_job.exc_info) - rq_job.delete() - + elif rq_job.is_failed or \ + rq_job.is_deferred and rq_job.dependency and rq_job.dependency.is_failed: + exc_info = process_failed_job(rq_job) # RQ adds a prefix with exception class name import_error_prefix = '{}.{}'.format( CvatImportError.__module__, CvatImportError.__name__) diff --git a/cvat/apps/engine/cloud_provider.py b/cvat/apps/engine/cloud_provider.py index 8beb8609168c..2acada19cc6a 100644 --- a/cvat/apps/engine/cloud_provider.py +++ b/cvat/apps/engine/cloud_provider.py @@ -658,3 +658,11 @@ def db_storage_to_storage_instance(db_storage): 'specific_attributes': db_storage.get_specific_attributes() } return get_cloud_storage_instance(cloud_provider=db_storage.provider_type, **details) + +@validate_bucket_status +def import_from_cloud_storage(storage, file_name): + return storage.download_fileobj(file_name) + +@validate_bucket_status +def export_to_cloud_storage(storage, file_path, file_name): + storage.upload_file(file_path, file_name) diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index fa4e6153f8f8..5a4d03d0fd0c 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -15,6 +16,7 @@ from cvat.apps.engine.models import Location from cvat.apps.engine.location import StorageType, get_location_configuration from cvat.apps.engine.serializers import DataSerializer, LabeledDataSerializer +from cvat.apps.webhooks.signals import signal_update, signal_create, signal_delete class TusFile: _tus_cache_timeout = 3600 @@ -279,6 +281,11 @@ def export_annotations(self, request, pk, db_obj, export_func, callback, get_dat return Response(serializer.data) def import_annotations(self, request, pk, db_obj, import_func, rq_func, rq_id): + is_tus_request = request.headers.get('Upload-Length', None) is not None or \ + request.method == 'OPTIONS' + if is_tus_request: + return self.init_tus_upload(request) + use_default_location = request.query_params.get('use_default_location', True) use_settings = strtobool(str(use_default_location)) obj = db_obj if use_settings else request.query_params @@ -316,6 +323,12 @@ def deserialize(self, request, import_func): return import_func(request, filename=file_name) return self.upload_data(request) + +class CreateModelMixin(mixins.CreateModelMixin): + def perform_create(self, serializer, **kwargs): + serializer.save(**kwargs) + signal_create.send(self, instance=serializer.instance) + class PartialUpdateModelMixin: """ Update fields of a model instance. @@ -324,8 +337,25 @@ class PartialUpdateModelMixin: """ def perform_update(self, serializer): + instance = serializer.instance + data = serializer.to_representation(instance) + old_values = { + attr: data[attr] if attr in data else getattr(instance, attr, None) + for attr in self.request.data.keys() + } + mixins.UpdateModelMixin.perform_update(self, serializer=serializer) + if getattr(serializer.instance, '_prefetched_objects_cache', None): + serializer.instance._prefetched_objects_cache = {} + + signal_update.send(self, instance=serializer.instance, old_values=old_values) + def partial_update(self, request, *args, **kwargs): kwargs['partial'] = True return mixins.UpdateModelMixin.update(self, request=request, *args, **kwargs) + +class DestroyModelMixin(mixins.DestroyModelMixin): + def perform_destroy(self, instance): + signal_delete.send(self, instance=instance) + super().perform_destroy(instance) diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 3a1fc6c0cba4..f5d8efe868ee 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -468,6 +468,9 @@ def get_project_id(self): project = self.segment.task.project return project.id if project else None + def get_organization_id(self): + return self.segment.task.organization + def get_bug_tracker(self): task = self.segment.task project = task.project @@ -675,6 +678,12 @@ class Issue(models.Model): updated_date = models.DateTimeField(null=True, blank=True) resolved = models.BooleanField(default=False) + def get_project_id(self): + return self.job.get_project_id() + + def get_organization_id(self): + return self.job.get_organization_id() + class Comment(models.Model): issue = models.ForeignKey(Issue, related_name='comments', on_delete=models.CASCADE) owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) @@ -682,6 +691,12 @@ class Comment(models.Model): created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) + def get_project_id(self): + return self.issue.get_project_id() + + def get_organization_id(self): + return self.issue.get_organization_id() + class CloudProviderChoice(str, Enum): AWS_S3 = 'AWS_S3_BUCKET' AZURE_CONTAINER = 'AZURE_CONTAINER' diff --git a/cvat/apps/engine/schema.py b/cvat/apps/engine/schema.py index c228d67f3a9f..8ebd21def6a8 100644 --- a/cvat/apps/engine/schema.py +++ b/cvat/apps/engine/schema.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: MIT +import textwrap from typing import Type from rest_framework import serializers from drf_spectacular.extensions import OpenApiSerializerExtension @@ -78,6 +79,8 @@ class WriteOnceSerializerExtension(OpenApiSerializerExtension): """ Enables support for cvat.apps.engine.serializers.WriteOnceMixin in drf-spectacular. Doesn't block other extensions on the target serializer. + + Removes the WriteOnceMixin class docstring from derived class descriptions. """ match_subclasses = True @@ -94,13 +97,20 @@ def _matches(cls, target) -> bool: return False def map_serializer(self, auto_schema, direction): - return auto_schema._map_serializer( + from cvat.apps.engine.serializers import WriteOnceMixin + + schema = auto_schema._map_serializer( _copy_serializer(self.target, context={ 'view': auto_schema.view, self._PROCESSED_INDICATOR_NAME: True }), direction, bypass_extensions=False) + if schema.get('description') == textwrap.dedent(WriteOnceMixin.__doc__).strip(): + del schema['description'] + + return schema + class OpenApiTypeProxySerializerExtension(PolymorphicProxySerializerExtension): """ Provides support for OpenApiTypes in the PolymorphicProxySerializer list diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index 33da5f56faea..8695864f313b 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -816,12 +816,13 @@ def update_labels(labels, parent_label=None): sublabels = label.pop('sublabels', []) svg = label.pop('svg', '') db_label = LabelSerializer.update_instance(label, instance, parent_label) - update_labels(sublabels, parent_label=db_label) + if not label.get('deleted'): + update_labels(sublabels, parent_label=db_label) - if label.get('id') is None and db_label.type == str(models.LabelType.SKELETON): - for db_sublabel in list(db_label.sublabels.all()): - svg = svg.replace(f'data-label-name="{db_sublabel.name}"', f'data-label-id="{db_sublabel.id}"') - models.Skeleton.objects.create(root=db_label, svg=svg) + if label.get('id') is None and db_label.type == str(models.LabelType.SKELETON): + for db_sublabel in list(db_label.sublabels.all()): + svg = svg.replace(f'data-label-name="{db_sublabel.name}"', f'data-label-id="{db_sublabel.id}"') + models.Skeleton.objects.create(root=db_label, svg=svg) update_labels(labels) @@ -960,6 +961,7 @@ class LabeledDataSerializer(serializers.Serializer): class FileInfoSerializer(serializers.Serializer): name = serializers.CharField(max_length=1024) type = serializers.ChoiceField(choices=["REG", "DIR"]) + mime_type = serializers.CharField(max_length=255) class LogEventSerializer(serializers.Serializer): job_id = serializers.IntegerField(required=False) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 43b82ab405c8..a503c811ea46 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -504,7 +504,7 @@ def test_api_v2_server_about_user(self): def test_api_v2_server_about_no_auth(self): response = self._run_api_v2_server_about(None) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_api_server_about_versions_admin(self): for version in settings.REST_FRAMEWORK['ALLOWED_VERSIONS']: diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index 7edbd07b7ca5..aa2537a04c77 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -30,9 +30,17 @@ query_string=True)), # documentation for API - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), - path('api/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger'), - path('api/docs/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('api/schema/', SpectacularAPIView.as_view( + permission_classes=[] # This endpoint is available for everyone + ), name='schema'), + path('api/swagger/', SpectacularSwaggerView.as_view( + url_name='schema', + permission_classes=[] # This endpoint is available for everyone + ), name='swagger'), + path('api/docs/', SpectacularRedocView.as_view( + url_name='schema', + permission_classes=[] # This endpoint is available for everyone + ), name='redoc'), # entry point for API path('api/', include('cvat.apps.iam.urls')), diff --git a/cvat/apps/engine/utils.py b/cvat/apps/engine/utils.py index a72bdfb909de..ead30d654fde 100644 --- a/cvat/apps/engine/utils.py +++ b/cvat/apps/engine/utils.py @@ -108,3 +108,28 @@ def parse_specific_attributes(specific_attributes): return { key: value for (key, value) in parsed_specific_attributes } if parsed_specific_attributes else dict() + + +def process_failed_job(rq_job): + if rq_job.meta['tmp_file_descriptor']: + os.close(rq_job.meta['tmp_file_descriptor']) + if os.path.exists(rq_job.meta['tmp_file']): + os.remove(rq_job.meta['tmp_file']) + exc_info = str(rq_job.exc_info) or str(rq_job.dependency.exc_info) + if rq_job.dependency: + rq_job.dependency.delete() + rq_job.delete() + + return exc_info + +def configure_dependent_job(queue, rq_id, rq_func, db_storage, filename, key): + rq_job_id_download_file = rq_id + f'?action=download_{filename}' + rq_job_download_file = queue.fetch_job(rq_job_id_download_file) + if not rq_job_download_file: + # note: boto3 resource isn't pickleable, so we can't use storage + rq_job_download_file = queue.enqueue_call( + func=rq_func, + args=(db_storage, filename, key), + job_id=rq_job_id_download_file + ) + return rq_job_download_file diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index dff6aa2448d5..32a7391097e0 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -44,12 +44,15 @@ import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import from cvat.apps.engine.cloud_provider import ( - db_storage_to_storage_instance, validate_bucket_status, Status as CloudStorageStatus) + db_storage_to_storage_instance, import_from_cloud_storage, export_to_cloud_storage, + Status as CloudStorageStatus +) from cvat.apps.dataset_manager.bindings import CvatImportError from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import FrameProvider from cvat.apps.engine.media_extractors import ImageListReader from cvat.apps.engine.mime_types import mimetypes +from cvat.apps.engine.media_extractors import get_mime from cvat.apps.engine.models import ( Job, Task, Project, Issue, Data, Comment, StorageMethodChoice, StorageChoice, Image, @@ -67,9 +70,10 @@ ProjectFileSerializer, TaskFileSerializer) from utils.dataset_manifest import ImageManifestManager -from cvat.apps.engine.utils import av_scan_paths +from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job from cvat.apps.engine import backup -from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin +from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin, CreateModelMixin +from cvat.apps.engine.location import get_location_configuration, StorageType from . import models, task from .log import clogger, slogger @@ -93,7 +97,9 @@ def get_serializer(self, *args, **kwargs): responses={ '200': AboutSerializer, }) - @action(detail=False, methods=['GET'], serializer_class=AboutSerializer) + @action(detail=False, methods=['GET'], serializer_class=AboutSerializer, + permission_classes=[] # This endpoint is available for everyone + ) def about(request): from cvat import __version__ as cvat_version about = { @@ -183,13 +189,20 @@ def share(request): content = os.scandir(directory) for entry in content: entry_type = None + entry_mime_type = None if entry.is_file(): entry_type = "REG" + entry_mime_type = get_mime(os.path.join(settings.SHARE_ROOT, entry)) elif entry.is_dir(): entry_type = "DIR" + entry_mime_type = "DIR" if entry_type: - data.append({"name": entry.name, "type": entry_type}) + data.append({ + "name": entry.name, + "type": entry_type, + "mime_type": entry_mime_type, + }) serializer = FileInfoSerializer(many=True, data=data) if serializer.is_valid(raise_exception=True): @@ -259,7 +272,7 @@ def plugins(request): }) ) class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = models.Project.objects.prefetch_related(Prefetch('label_set', @@ -294,8 +307,11 @@ def get_queryset(self): return queryset def perform_create(self, serializer): - serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) @extend_schema( summary='Method returns information of the tasks of the project with the selected id', @@ -392,14 +408,16 @@ def dataset(self, request, pk): elif rq_job.is_finished: if rq_job.meta['tmp_file_descriptor']: os.close(rq_job.meta['tmp_file_descriptor']) os.remove(rq_job.meta['tmp_file']) + if rq_job.dependency: + rq_job.dependency.delete() rq_job.delete() return Response(status=status.HTTP_201_CREATED) - elif rq_job.is_failed: - if rq_job.meta['tmp_file_descriptor']: os.close(rq_job.meta['tmp_file_descriptor']) - os.remove(rq_job.meta['tmp_file']) - rq_job.delete() + elif rq_job.is_failed or \ + rq_job.is_deferred and rq_job.dependency and rq_job.dependency.is_failed: + exc_info = process_failed_job(rq_job) + return Response( - data=str(rq_job.exc_info), + data=str(exc_info), status=status.HTTP_500_INTERNAL_SERVER_ERROR ) else: @@ -697,7 +715,7 @@ def __call__(self, request, start, stop, db_data, db_object): }) ) class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = Task.objects.prefetch_related( @@ -785,19 +803,26 @@ def export_backup(self, request, pk=None): def perform_update(self, serializer): instance = serializer.instance - updated_instance = serializer.save() + + super().perform_update(serializer) + + updated_instance = serializer.instance + if instance.project: instance.project.save() if updated_instance.project: updated_instance.project.save() def perform_create(self, serializer): - instance = serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) - if instance.project: - db_project = instance.project + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) + if serializer.instance.project: + db_project = serializer.instance.project db_project.save() - assert instance.organization == db_project.organization + assert serializer.instance.organization == db_project.organization def perform_destroy(self, instance): task_dirname = instance.get_dirname() @@ -810,6 +835,7 @@ def perform_destroy(self, instance): db_project = instance.project db_project.save() + @extend_schema(summary='Method returns a list of jobs for a specific task', responses=JobReadSerializer(many=True)) # Duplicate to still get 'list' op. name @action(detail=True, methods=['GET'], serializer_class=JobReadSerializer(many=True), @@ -837,7 +863,6 @@ def get_upload_dir(self): # UploadMixin method def upload_finished(self, request): if self.action == 'annotations': - # db_task = self.get_object() format_name = request.query_params.get("format", "") filename = request.query_params.get("filename", "") tmp_dir = self._object.get_tmp_dirname() @@ -1073,12 +1098,18 @@ def annotations(self, request, pk): elif request.method == 'PUT': format_name = request.query_params.get('format') if format_name: + use_settings = strtobool(str(request.query_params.get('use_default_location', True))) + obj = self._object if use_settings else request.query_params + location_conf = get_location_configuration( + obj=obj, use_settings=use_settings, field_name=StorageType.SOURCE + ) return _import_annotations( request=request, rq_id="{}@/api/tasks/{}/annotations/upload".format(request.user, pk), rq_func=dm.task.import_task_annotations, pk=pk, format_name=format_name, + location_conf=location_conf ) else: serializer = LabeledDataSerializer(data=request.data) @@ -1417,12 +1448,18 @@ def annotations(self, request, pk): elif request.method == 'PUT': format_name = request.query_params.get('format', '') if format_name: + use_settings = strtobool(str(request.query_params.get('use_default_location', True))) + obj = self._object.segment.task if use_settings else request.query_params + location_conf = get_location_configuration( + obj=obj, use_settings=use_settings, field_name=StorageType.SOURCE + ) return _import_annotations( request=request, rq_id="{}@/api/jobs/{}/annotations/upload".format(request.user, pk), rq_func=dm.task.import_job_annotations, pk=pk, - format_name=format_name + format_name=format_name, + location_conf=location_conf ) else: serializer = LabeledDataSerializer(data=request.data) @@ -1448,6 +1485,7 @@ def annotations(self, request, pk): return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST) return Response(data) + @extend_schema(methods=['PATCH'], operation_id='jobs_partial_update_annotations_file', summary="Allows to upload an annotation file chunk. " @@ -1463,6 +1501,7 @@ def append_annotations_chunk(self, request, pk, file_id): self._object = self.get_object() return self.append_tus_chunk(request, file_id) + @extend_schema(summary='Export job as a dataset in a specific format', parameters=[ OpenApiParameter('format', location=OpenApiParameter.QUERY, @@ -1515,6 +1554,7 @@ def issues(self, request, pk): return Response(serializer.data) + @extend_schema(summary='Method returns data for a specific job', parameters=[ OpenApiParameter('type', description='Specifies the type of the requested data', @@ -1542,6 +1582,7 @@ def data(self, request, pk): return data_getter(request, db_job.segment.start_frame, db_job.segment.stop_frame, db_job.segment.task.data, db_job) + @extend_schema(summary='Method provides a meta information about media files which are related with the job', responses={ '200': DataMetaReadSerializer, @@ -1661,7 +1702,7 @@ def commits(self, request, pk): }) ) class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Issue.objects.all().order_by('-id') @@ -1692,7 +1733,7 @@ def get_serializer_class(self): return IssueWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) + super().perform_create(serializer, owner=self.request.user) @extend_schema(summary='The action returns all comments of a specific issue', responses=CommentReadSerializer(many=True)) # Duplicate to still get 'list' op. name @@ -1741,7 +1782,7 @@ def comments(self, request, pk): }) ) class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Comment.objects.all().order_by('-id') @@ -1767,7 +1808,7 @@ def get_serializer_class(self): return CommentWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) + super().perform_create(serializer, owner=self.request.user) @extend_schema(tags=['users']) @extend_schema_view( @@ -2115,13 +2156,12 @@ def rq_handler(job, exc_type, exc_value, tb): return True -@validate_bucket_status -def _export_to_cloud_storage(storage, file_path, file_name): - storage.upload_file(file_path, file_name) +def _download_file_from_bucket(db_storage, filename, key): + storage = db_storage_to_storage_instance(db_storage) -@validate_bucket_status -def _import_from_cloud_storage(storage, file_name): - return storage.download_fileobj(file_name) + data = import_from_cloud_storage(storage, key) + with open(filename, 'wb+') as f: + f.write(data.getbuffer()) def _import_annotations(request, rq_id, rq_func, pk, format_name, filename=None, location_conf=None): @@ -2141,6 +2181,7 @@ def _import_annotations(request, rq_id, rq_func, pk, format_name, # Then we dont need to create temporary file # Or filename specify key in cloud storage so we need to download file fd = None + dependent_job = None location = location_conf.get('location') if location_conf else Location.LOCAL if not filename or location == Location.CLOUD_STORAGE: @@ -2153,28 +2194,26 @@ def _import_annotations(request, rq_id, rq_func, pk, format_name, for chunk in anno_file.chunks(): f.write(chunk) else: - # download annotation file from cloud storage + assert filename, 'The filename was not spesified' try: storage_id = location_conf['storage_id'] except KeyError: - raise serializer.ValidationError( + raise serializers.ValidationError( 'Cloud storage location was selected for destination' ' but cloud storage id was not specified') db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) - storage = db_storage_to_storage_instance(db_storage) - assert filename, 'filename was not spesified' - - data = _import_from_cloud_storage(storage, filename) - + key = filename fd, filename = mkstemp(prefix='cvat_{}'.format(pk), dir=settings.TMP_FILES_ROOT) - with open(filename, 'wb+') as f: - f.write(data.getbuffer()) + dependent_job = configure_dependent_job( + queue, rq_id, _download_file_from_bucket, + db_storage, filename, key) av_scan_paths(filename) rq_job = queue.enqueue_call( func=rq_func, args=(pk, filename, format_name), - job_id=rq_id + job_id=rq_id, + depends_on=dependent_job ) rq_job.meta['tmp_file'] = filename rq_job.meta['tmp_file_descriptor'] = fd @@ -2185,12 +2224,9 @@ def _import_annotations(request, rq_id, rq_func, pk, format_name, os.remove(rq_job.meta['tmp_file']) rq_job.delete() return Response(status=status.HTTP_201_CREATED) - elif rq_job.is_failed: - if rq_job.meta['tmp_file_descriptor']: os.close(rq_job.meta['tmp_file_descriptor']) - os.remove(rq_job.meta['tmp_file']) - exc_info = str(rq_job.exc_info) - rq_job.delete() - + elif rq_job.is_failed or \ + rq_job.is_deferred and rq_job.dependency and rq_job.dependency.is_failed: + exc_info = process_failed_job(rq_job) # RQ adds a prefix with exception class name import_error_prefix = '{}.{}'.format( CvatImportError.__module__, CvatImportError.__name__) @@ -2243,13 +2279,13 @@ def _export_annotations(db_instance, rq_id, request, format_name, action, callba db_instance.__class__.__name__.lower(), db_instance.name if isinstance(db_instance, (Task, Project)) else db_instance.id, timestamp, format_name, osp.splitext(file_path)[1] - ) + ).lower() # save annotation to specified location location = location_conf.get('location') if location == Location.LOCAL: return sendfile(request, file_path, attachment=True, - attachment_filename=filename.lower()) + attachment_filename=filename) elif location == Location.CLOUD_STORAGE: try: storage_id = location_conf['storage_id'] @@ -2261,7 +2297,7 @@ def _export_annotations(db_instance, rq_id, request, format_name, action, callba db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) storage = db_storage_to_storage_instance(db_storage) - _export_to_cloud_storage(storage, file_path, filename) + export_to_cloud_storage(storage, file_path, filename) return Response(status=status.HTTP_200_OK) else: raise NotImplementedError() @@ -2309,6 +2345,7 @@ def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=N if not rq_job: fd = None + dependent_job = None location = location_conf.get('location') if location_conf else None if not filename and location != Location.CLOUD_STORAGE: serializer = DatasetFileSerializer(data=request.data) @@ -2319,9 +2356,7 @@ def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=N for chunk in dataset_file.chunks(): f.write(chunk) elif location == Location.CLOUD_STORAGE: - assert filename - - # download project file from cloud storage + assert filename, 'The filename was not spesified' try: storage_id = location_conf['storage_id'] except KeyError: @@ -2329,23 +2364,22 @@ def _import_project_dataset(request, rq_id, rq_func, pk, format_name, filename=N 'Cloud storage location was selected for destination' ' but cloud storage id was not specified') db_storage = get_object_or_404(CloudStorageModel, pk=storage_id) - storage = db_storage_to_storage_instance(db_storage) - - data = _import_from_cloud_storage(storage, filename) - - fd, filename = mkstemp(prefix='cvat_', dir=settings.TMP_FILES_ROOT) - with open(filename, 'wb+') as f: - f.write(data.getbuffer()) + key = filename + fd, filename = mkstemp(prefix='cvat_{}'.format(pk), dir=settings.TMP_FILES_ROOT) + dependent_job = configure_dependent_job( + queue, rq_id, _download_file_from_bucket, + db_storage, filename, key) rq_job = queue.enqueue_call( func=rq_func, args=(pk, filename, format_name), job_id=rq_id, meta={ - 'tmp_file': filename, - 'tmp_file_descriptor': fd, - }, - ) + 'tmp_file': filename, + 'tmp_file_descriptor': fd, + }, + depends_on=dependent_job + ) else: return Response(status=status.HTTP_409_CONFLICT, data='Import job already exists') diff --git a/cvat/apps/iam/permissions.py b/cvat/apps/iam/permissions.py index fe5a6d152a39..4774ea378b59 100644 --- a/cvat/apps/iam/permissions.py +++ b/cvat/apps/iam/permissions.py @@ -1,4 +1,5 @@ # Copyright (C) 2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -12,6 +13,7 @@ from django.db.models import Q from rest_framework.permissions import BasePermission +from cvat.apps.webhooks.models import Webhook from cvat.apps.organizations.models import Membership, Organization from cvat.apps.engine.models import Project, Task, Job, Issue @@ -763,6 +765,99 @@ def get_resource(self): return data + +class WebhookPermission(OpenPolicyAgentPermission): + @classmethod + def create(cls, request, view, obj): + permissions = [] + if view.basename == 'webhook': + + project_id = request.data.get('project_id') + for scope in cls.get_scopes(request, view, obj): + self = cls.create_base_perm(request, view, scope, obj, + project_id=project_id) + permissions.append(self) + + owner = request.data.get('owner_id') or request.data.get('owner') + if owner: + perm = UserPermission.create_scope_view(request, owner) + permissions.append(perm) + + if project_id: + perm = ProjectPermission.create_scope_view(request, project_id) + permissions.append(perm) + + return permissions + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.url = settings.IAM_OPA_DATA_URL + '/webhooks/allow' + + @staticmethod + def get_scopes(request, view, obj): + scope = { + ('create', 'POST'): 'create', + ('destroy', 'DELETE'): 'delete', + ('partial_update', 'PATCH'): 'update', + ('update', 'PUT'): 'update', + ('list', 'GET'): 'list', + ('retrieve', 'GET'): 'view', + }.get((view.action, request.method)) + + scopes = [] + if scope == 'create': + webhook_type = request.data.get('type') + if webhook_type: + scope += f'@{webhook_type}' + scopes.append(scope) + elif scope in ['update', 'delete', 'list', 'view']: + scopes.append(scope) + + return scopes + + def get_resource(self): + data = None + if self.obj: + data = { + "id": self.obj.id, + "owner": {"id": getattr(self.obj.owner, 'id', None) }, + 'organization': { + "id": getattr(self.obj.organization, 'id', None) + }, + "project": None + } + if self.obj.type == 'project' and getattr(self.obj, 'project', None): + data['project'] = { + 'owner': {'id': getattr(self.obj.project.owner, 'id', None)} + } + elif self.scope in ['create@project', 'create@organization']: + project = None + if self.project_id: + try: + project = Project.objects.get(id=self.project_id) + except Project.DoesNotExist: + raise ValidationError(f"Could not find project with provided id: {self.project_id}") + + num_resources = Webhook.objects.filter(project=self.project_id).count() if project \ + else Webhook.objects.filter(organization=self.org_id, project=None).count() + + data = { + 'id': None, + 'owner': self.user_id, + 'organization': { + 'id': self.org_id + }, + 'num_resources': num_resources + } + + data['project'] = None if project is None else { + 'owner': { + 'id': getattr(project.owner, 'id', None) + }, + } + + return data + class JobPermission(OpenPolicyAgentPermission): @classmethod def create(cls, request, view, obj): @@ -1029,6 +1124,7 @@ def get_common_data(db_job): return data + class PolicyEnforcer(BasePermission): # pylint: disable=no-self-use def check_permission(self, request, view, obj): @@ -1071,3 +1167,4 @@ def has_permission(self, request, view): return membership is not None return True + diff --git a/cvat/apps/iam/rules/utils.rego b/cvat/apps/iam/rules/utils.rego index 430427f78c2a..99025da1f244 100644 --- a/cvat/apps/iam/rules/utils.rego +++ b/cvat/apps/iam/rules/utils.rego @@ -35,6 +35,7 @@ UPDATE_OWNER := "update:owner" EXPORT_ANNOTATIONS := "export:annotations" EXPORT_DATASET := "export:dataset" CREATE_IN_PROJECT := "create@project" +CREATE_IN_ORGANIZATION := "create@organization" UPDATE_PROJECT := "update:project" VIEW_ANNOTATIONS := "view:annotations" UPDATE_ANNOTATIONS := "update:annotations" diff --git a/cvat/apps/iam/rules/webhooks.csv b/cvat/apps/iam/rules/webhooks.csv new file mode 100644 index 000000000000..eb87a418b916 --- /dev/null +++ b/cvat/apps/iam/rules/webhooks.csv @@ -0,0 +1,21 @@ +Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership +create@project,Webhook,Sandbox,N/A,,POST,/webhooks,Admin,N/A +create@project,Webhook,Sandbox,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,N/A +create@project,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer +create@project,Webhook,Organization,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,Worker +create@organization,Webhook,Organization,N/A,,POST,/webhooks,Admin,N/A +create@organization,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer +update,Webhook,Sandbox,N/A,,PATCH,/webhooks/{id},Admin,N/A +update,Webhook,Sandbox,"Project:owner, owner",,PATCH,/webhooks/{id},Worker,N/A +update,Webhook,Organization,N/A,,PATCH,/webhooks/{id},Worker,Maintainer +update,Webhook,Organization,"Project:owner, owner",,PATCH,/webhooks/{id},Worker,Worker +delete,Webhook,Sandbox,N/A,,DELETE,/webhooks/{id},Admin,N/A +delete,Webhook,Sandbox,"Project:owner, owner",,DELETE,/webhooks/{id},Worker,N/A +delete,Webhook,Organization,N/A,,DELETE,/webhooks/{id},Worker,Maintainer +delete,Webhook,Organization,"Project:owner, owner",,DELETE,/webhooks/{id},Worker,Worker +view,Webhook,Sandbox,N/A,,GET,/webhooks/{id},Admin,N/A +view,Webhook,Sandbox,"Project:owner, owner",,GET,/webhooks/{id},None,N/A +view,Webhook,Organization,N/A,,GET,/webhooks/{id},Worker,Maintainer +view,Webhook,Organization,"Project:owner, owner",,GET,/webhooks/{id},None,Worker +list,N/A,Sandbox,N/A,,GET,/webhooks,None,N/A +list,N/A,Organization,N/A,,GET,/webhooks,None,Worker diff --git a/cvat/apps/iam/rules/webhooks.rego b/cvat/apps/iam/rules/webhooks.rego new file mode 100644 index 000000000000..bf28ecaf9407 --- /dev/null +++ b/cvat/apps/iam/rules/webhooks.rego @@ -0,0 +1,173 @@ +package webhooks +import data.utils +import data.organizations + +# input : { +# "scope": <"create@project" | "create@organization" | "update" | "delete" | +# "list" | "view"> or null, +# "auth": { +# "user": { +# "id": +# "privilege": <"admin"|"business"|"user"|"worker"> or null +# } +# "organization": { +# "id": , +# "owner": +# "id": +# }, +# "user": { +# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null +# } +# } or null, +# }, +# "resource": { +# "id": , +# "owner": { "id": }, +# "organization": { "id": } or null, +# "project": { +# "owner": { "id": num }, +# } or null, +# "num_resources": +# } +# } +# + +is_project_owner { + input.resource.project.owner.id == input.auth.user.id +} + +is_webhook_owner { + input.resource.owner.id == input.auth.user.id +} + +default allow = false + +allow { + utils.is_admin +} + +allow { + input.scope == utils.CREATE_IN_PROJECT + utils.is_sandbox + utils.has_perm(utils.USER) + is_project_owner + input.resource.num_resources < 10 +} + + +allow { + input.scope == utils.LIST + utils.is_sandbox +} + +allow { + input.scope == utils.LIST + organizations.is_member +} + +filter = [] { # Django Q object to filter list of entries + utils.is_admin + utils.is_sandbox +} else = qobject { + utils.is_admin + utils.is_organization + qobject := [ {"organization": input.auth.organization.id} ] +} else = qobject { + utils.is_sandbox + user := input.auth.user + qobject := [ {"owner_id": user.id}, {"project__owner_id": user.id}, "|" ] +} else = qobject { + utils.is_organization + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.MAINTAINER) + qobject := [ {"organization": input.auth.organization.id} ] +} else = qobject { + utils.is_organization + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.WORKER) + user := input.auth.user + qobject := [ {"owner_id": user.id}, {"project__owner_id": user.id}, + "|", {"organization": input.auth.organization.id}, "&"] +} + + +allow { + input.scope == utils.VIEW + utils.is_sandbox + utils.is_resource_owner +} + +allow { + input.scope == utils.VIEW + utils.is_sandbox + is_project_owner +} + +allow { + { utils.UPDATE, utils.DELETE }[input.scope] + utils.is_sandbox + utils.has_perm(utils.WORKER) + utils.is_resource_owner +} + +allow { + { utils.UPDATE, utils.DELETE }[input.scope] + utils.is_sandbox + utils.has_perm(utils.WORKER) + is_project_owner +} + +allow { + input.scope == utils.VIEW + input.auth.organization.id == input.resource.organization.id + organizations.has_perm(organizations.WORKER) + utils.is_resource_owner +} + +allow { + input.scope == utils.VIEW + input.auth.organization.id == input.resource.organization.id + organizations.has_perm(organizations.WORKER) + is_project_owner +} + +allow { + { utils.UPDATE, utils.DELETE }[input.scope] + input.auth.organization.id == input.resource.organization.id + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.WORKER) + utils.is_resource_owner +} + + +allow { + { utils.UPDATE, utils.DELETE, utils.VIEW }[input.scope] + input.auth.organization.id == input.resource.organization.id + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.MAINTAINER) +} + +allow { + { utils.CREATE_IN_PROJECT, utils.CREATE_IN_ORGANIZATION }[input.scope] + input.auth.organization.id == input.resource.organization.id + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.MAINTAINER) + input.resource.num_resources < 10 +} + +allow { + { utils.UPDATE, utils.DELETE }[input.scope] + input.auth.organization.id == input.resource.organization.id + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.WORKER) + is_project_owner +} + +allow { + { utils.CREATE_IN_PROJECT }[input.scope] + input.auth.organization.id == input.resource.organization.id + utils.has_perm(utils.WORKER) + organizations.has_perm(organizations.WORKER) + input.resource.num_resources < 10 + is_project_owner +} diff --git a/cvat/apps/iam/rules/webhooks_test.gen.rego b/cvat/apps/iam/rules/webhooks_test.gen.rego new file mode 100644 index 000000000000..1eabc9a1fd71 --- /dev/null +++ b/cvat/apps/iam/rules/webhooks_test.gen.rego @@ -0,0 +1,2206 @@ +package webhooks + +test_scope_VIEW_context_SANDBOX_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 97, "privilege": "admin"}, "organization": null}, "resource": {"id": 157, "owner": {"id": 275}, "organization": {"id": 335}, "project": {"owner": {"id": 97}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 12, "privilege": "business"}, "organization": null}, "resource": {"id": 198, "owner": {"id": 237}, "organization": {"id": 310}, "project": {"owner": {"id": 12}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 27, "privilege": "user"}, "organization": null}, "resource": {"id": 140, "owner": {"id": 251}, "organization": {"id": 334}, "project": {"owner": {"id": 27}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 76, "privilege": "worker"}, "organization": null}, "resource": {"id": 176, "owner": {"id": 208}, "organization": {"id": 349}, "project": {"owner": {"id": 76}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 99, "privilege": "none"}, "organization": null}, "resource": {"id": 147, "owner": {"id": 297}, "organization": {"id": 320}, "project": {"owner": {"id": 99}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 85, "privilege": "admin"}, "organization": null}, "resource": {"id": 125, "owner": {"id": 85}, "organization": {"id": 339}, "project": {"owner": {"id": 451}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 52, "privilege": "business"}, "organization": null}, "resource": {"id": 173, "owner": {"id": 52}, "organization": {"id": 331}, "project": {"owner": {"id": 460}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 96, "privilege": "user"}, "organization": null}, "resource": {"id": 183, "owner": {"id": 96}, "organization": {"id": 301}, "project": {"owner": {"id": 411}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 7, "privilege": "worker"}, "organization": null}, "resource": {"id": 161, "owner": {"id": 7}, "organization": {"id": 367}, "project": {"owner": {"id": 420}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 58, "privilege": "none"}, "organization": null}, "resource": {"id": 140, "owner": {"id": 58}, "organization": {"id": 309}, "project": {"owner": {"id": 401}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 43, "privilege": "admin"}, "organization": null}, "resource": {"id": 136, "owner": {"id": 277}, "organization": {"id": 326}, "project": {"owner": {"id": 491}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 39, "privilege": "business"}, "organization": null}, "resource": {"id": 118, "owner": {"id": 255}, "organization": {"id": 316}, "project": {"owner": {"id": 405}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 28, "privilege": "user"}, "organization": null}, "resource": {"id": 158, "owner": {"id": 244}, "organization": {"id": 339}, "project": {"owner": {"id": 429}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 0, "privilege": "worker"}, "organization": null}, "resource": {"id": 132, "owner": {"id": 205}, "organization": {"id": 390}, "project": {"owner": {"id": 455}}}} +} + +test_scope_VIEW_context_SANDBOX_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 70, "privilege": "none"}, "organization": null}, "resource": {"id": 178, "owner": {"id": 272}, "organization": {"id": 338}, "project": {"owner": {"id": 451}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 41, "privilege": "admin"}, "organization": {"id": 159, "owner": {"id": 41}, "user": {"role": "owner"}}}, "resource": {"id": 155, "owner": {"id": 274}, "organization": {"id": 159}, "project": {"owner": {"id": 41}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 60, "privilege": "admin"}, "organization": {"id": 194, "owner": {"id": 60}, "user": {"role": "owner"}}}, "resource": {"id": 156, "owner": {"id": 286}, "organization": {"id": 327}, "project": {"owner": {"id": 60}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 84, "privilege": "admin"}, "organization": {"id": 181, "owner": {"id": 279}, "user": {"role": "maintainer"}}}, "resource": {"id": 184, "owner": {"id": 210}, "organization": {"id": 181}, "project": {"owner": {"id": 84}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 86, "privilege": "admin"}, "organization": {"id": 139, "owner": {"id": 228}, "user": {"role": "maintainer"}}}, "resource": {"id": 142, "owner": {"id": 211}, "organization": {"id": 396}, "project": {"owner": {"id": 86}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 31, "privilege": "admin"}, "organization": {"id": 160, "owner": {"id": 278}, "user": {"role": "supervisor"}}}, "resource": {"id": 125, "owner": {"id": 218}, "organization": {"id": 160}, "project": {"owner": {"id": 31}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 80, "privilege": "admin"}, "organization": {"id": 173, "owner": {"id": 224}, "user": {"role": "supervisor"}}}, "resource": {"id": 198, "owner": {"id": 209}, "organization": {"id": 358}, "project": {"owner": {"id": 80}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 51, "privilege": "admin"}, "organization": {"id": 131, "owner": {"id": 218}, "user": {"role": "worker"}}}, "resource": {"id": 191, "owner": {"id": 289}, "organization": {"id": 131}, "project": {"owner": {"id": 51}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 98, "privilege": "admin"}, "organization": {"id": 113, "owner": {"id": 299}, "user": {"role": "worker"}}}, "resource": {"id": 183, "owner": {"id": 288}, "organization": {"id": 300}, "project": {"owner": {"id": 98}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 66, "privilege": "admin"}, "organization": {"id": 159, "owner": {"id": 206}, "user": {"role": null}}}, "resource": {"id": 154, "owner": {"id": 228}, "organization": {"id": 159}, "project": {"owner": {"id": 66}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 17, "privilege": "admin"}, "organization": {"id": 159, "owner": {"id": 285}, "user": {"role": null}}}, "resource": {"id": 171, "owner": {"id": 231}, "organization": {"id": 315}, "project": {"owner": {"id": 17}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 96, "privilege": "business"}, "organization": {"id": 156, "owner": {"id": 96}, "user": {"role": "owner"}}}, "resource": {"id": 167, "owner": {"id": 271}, "organization": {"id": 156}, "project": {"owner": {"id": 96}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 57, "privilege": "business"}, "organization": {"id": 120, "owner": {"id": 57}, "user": {"role": "owner"}}}, "resource": {"id": 192, "owner": {"id": 264}, "organization": {"id": 354}, "project": {"owner": {"id": 57}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 31, "privilege": "business"}, "organization": {"id": 181, "owner": {"id": 235}, "user": {"role": "maintainer"}}}, "resource": {"id": 160, "owner": {"id": 257}, "organization": {"id": 181}, "project": {"owner": {"id": 31}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 80, "privilege": "business"}, "organization": {"id": 130, "owner": {"id": 235}, "user": {"role": "maintainer"}}}, "resource": {"id": 198, "owner": {"id": 299}, "organization": {"id": 366}, "project": {"owner": {"id": 80}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 30, "privilege": "business"}, "organization": {"id": 134, "owner": {"id": 242}, "user": {"role": "supervisor"}}}, "resource": {"id": 156, "owner": {"id": 209}, "organization": {"id": 134}, "project": {"owner": {"id": 30}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 19, "privilege": "business"}, "organization": {"id": 129, "owner": {"id": 249}, "user": {"role": "supervisor"}}}, "resource": {"id": 140, "owner": {"id": 269}, "organization": {"id": 310}, "project": {"owner": {"id": 19}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 8, "privilege": "business"}, "organization": {"id": 153, "owner": {"id": 252}, "user": {"role": "worker"}}}, "resource": {"id": 188, "owner": {"id": 219}, "organization": {"id": 153}, "project": {"owner": {"id": 8}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 7, "privilege": "business"}, "organization": {"id": 126, "owner": {"id": 253}, "user": {"role": "worker"}}}, "resource": {"id": 142, "owner": {"id": 269}, "organization": {"id": 359}, "project": {"owner": {"id": 7}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 2, "privilege": "business"}, "organization": {"id": 197, "owner": {"id": 273}, "user": {"role": null}}}, "resource": {"id": 149, "owner": {"id": 298}, "organization": {"id": 197}, "project": {"owner": {"id": 2}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 38, "privilege": "business"}, "organization": {"id": 196, "owner": {"id": 249}, "user": {"role": null}}}, "resource": {"id": 148, "owner": {"id": 261}, "organization": {"id": 300}, "project": {"owner": {"id": 38}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 69, "privilege": "user"}, "organization": {"id": 177, "owner": {"id": 69}, "user": {"role": "owner"}}}, "resource": {"id": 153, "owner": {"id": 268}, "organization": {"id": 177}, "project": {"owner": {"id": 69}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 62, "privilege": "user"}, "organization": {"id": 103, "owner": {"id": 62}, "user": {"role": "owner"}}}, "resource": {"id": 162, "owner": {"id": 228}, "organization": {"id": 334}, "project": {"owner": {"id": 62}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 92, "privilege": "user"}, "organization": {"id": 121, "owner": {"id": 259}, "user": {"role": "maintainer"}}}, "resource": {"id": 143, "owner": {"id": 285}, "organization": {"id": 121}, "project": {"owner": {"id": 92}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 50, "privilege": "user"}, "organization": {"id": 175, "owner": {"id": 272}, "user": {"role": "maintainer"}}}, "resource": {"id": 116, "owner": {"id": 279}, "organization": {"id": 368}, "project": {"owner": {"id": 50}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 54, "privilege": "user"}, "organization": {"id": 117, "owner": {"id": 259}, "user": {"role": "supervisor"}}}, "resource": {"id": 184, "owner": {"id": 203}, "organization": {"id": 117}, "project": {"owner": {"id": 54}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 41, "privilege": "user"}, "organization": {"id": 127, "owner": {"id": 258}, "user": {"role": "supervisor"}}}, "resource": {"id": 123, "owner": {"id": 206}, "organization": {"id": 333}, "project": {"owner": {"id": 41}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 35, "privilege": "user"}, "organization": {"id": 196, "owner": {"id": 253}, "user": {"role": "worker"}}}, "resource": {"id": 141, "owner": {"id": 243}, "organization": {"id": 196}, "project": {"owner": {"id": 35}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 95, "privilege": "user"}, "organization": {"id": 169, "owner": {"id": 206}, "user": {"role": "worker"}}}, "resource": {"id": 132, "owner": {"id": 210}, "organization": {"id": 360}, "project": {"owner": {"id": 95}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 99, "privilege": "user"}, "organization": {"id": 183, "owner": {"id": 205}, "user": {"role": null}}}, "resource": {"id": 144, "owner": {"id": 228}, "organization": {"id": 183}, "project": {"owner": {"id": 99}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 2, "privilege": "user"}, "organization": {"id": 179, "owner": {"id": 219}, "user": {"role": null}}}, "resource": {"id": 196, "owner": {"id": 203}, "organization": {"id": 331}, "project": {"owner": {"id": 2}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "worker"}, "organization": {"id": 172, "owner": {"id": 14}, "user": {"role": "owner"}}}, "resource": {"id": 130, "owner": {"id": 216}, "organization": {"id": 172}, "project": {"owner": {"id": 14}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 47, "privilege": "worker"}, "organization": {"id": 121, "owner": {"id": 47}, "user": {"role": "owner"}}}, "resource": {"id": 159, "owner": {"id": 289}, "organization": {"id": 332}, "project": {"owner": {"id": 47}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 99, "privilege": "worker"}, "organization": {"id": 120, "owner": {"id": 239}, "user": {"role": "maintainer"}}}, "resource": {"id": 177, "owner": {"id": 295}, "organization": {"id": 120}, "project": {"owner": {"id": 99}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 73, "privilege": "worker"}, "organization": {"id": 186, "owner": {"id": 248}, "user": {"role": "maintainer"}}}, "resource": {"id": 113, "owner": {"id": 274}, "organization": {"id": 303}, "project": {"owner": {"id": 73}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 75, "privilege": "worker"}, "organization": {"id": 188, "owner": {"id": 280}, "user": {"role": "supervisor"}}}, "resource": {"id": 150, "owner": {"id": 291}, "organization": {"id": 188}, "project": {"owner": {"id": 75}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 38, "privilege": "worker"}, "organization": {"id": 187, "owner": {"id": 276}, "user": {"role": "supervisor"}}}, "resource": {"id": 131, "owner": {"id": 213}, "organization": {"id": 389}, "project": {"owner": {"id": 38}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 68, "privilege": "worker"}, "organization": {"id": 154, "owner": {"id": 284}, "user": {"role": "worker"}}}, "resource": {"id": 115, "owner": {"id": 272}, "organization": {"id": 154}, "project": {"owner": {"id": 68}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 43, "privilege": "worker"}, "organization": {"id": 101, "owner": {"id": 253}, "user": {"role": "worker"}}}, "resource": {"id": 147, "owner": {"id": 208}, "organization": {"id": 364}, "project": {"owner": {"id": 43}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 81, "privilege": "worker"}, "organization": {"id": 158, "owner": {"id": 290}, "user": {"role": null}}}, "resource": {"id": 162, "owner": {"id": 213}, "organization": {"id": 158}, "project": {"owner": {"id": 81}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 66, "privilege": "worker"}, "organization": {"id": 183, "owner": {"id": 234}, "user": {"role": null}}}, "resource": {"id": 119, "owner": {"id": 255}, "organization": {"id": 322}, "project": {"owner": {"id": 66}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 59, "privilege": "none"}, "organization": {"id": 155, "owner": {"id": 59}, "user": {"role": "owner"}}}, "resource": {"id": 178, "owner": {"id": 268}, "organization": {"id": 155}, "project": {"owner": {"id": 59}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 11, "privilege": "none"}, "organization": {"id": 135, "owner": {"id": 11}, "user": {"role": "owner"}}}, "resource": {"id": 175, "owner": {"id": 234}, "organization": {"id": 341}, "project": {"owner": {"id": 11}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 78, "privilege": "none"}, "organization": {"id": 185, "owner": {"id": 248}, "user": {"role": "maintainer"}}}, "resource": {"id": 131, "owner": {"id": 296}, "organization": {"id": 185}, "project": {"owner": {"id": 78}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 23, "privilege": "none"}, "organization": {"id": 162, "owner": {"id": 227}, "user": {"role": "maintainer"}}}, "resource": {"id": 143, "owner": {"id": 203}, "organization": {"id": 363}, "project": {"owner": {"id": 23}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 76, "privilege": "none"}, "organization": {"id": 189, "owner": {"id": 235}, "user": {"role": "supervisor"}}}, "resource": {"id": 145, "owner": {"id": 233}, "organization": {"id": 189}, "project": {"owner": {"id": 76}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 10, "privilege": "none"}, "organization": {"id": 130, "owner": {"id": 292}, "user": {"role": "supervisor"}}}, "resource": {"id": 171, "owner": {"id": 201}, "organization": {"id": 366}, "project": {"owner": {"id": 10}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 30, "privilege": "none"}, "organization": {"id": 188, "owner": {"id": 260}, "user": {"role": "worker"}}}, "resource": {"id": 152, "owner": {"id": 262}, "organization": {"id": 188}, "project": {"owner": {"id": 30}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 2, "privilege": "none"}, "organization": {"id": 111, "owner": {"id": 237}, "user": {"role": "worker"}}}, "resource": {"id": 182, "owner": {"id": 291}, "organization": {"id": 362}, "project": {"owner": {"id": 2}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 39, "privilege": "none"}, "organization": {"id": 184, "owner": {"id": 274}, "user": {"role": null}}}, "resource": {"id": 128, "owner": {"id": 251}, "organization": {"id": 184}, "project": {"owner": {"id": 39}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 44, "privilege": "none"}, "organization": {"id": 154, "owner": {"id": 295}, "user": {"role": null}}}, "resource": {"id": 147, "owner": {"id": 260}, "organization": {"id": 370}, "project": {"owner": {"id": 44}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 58, "privilege": "admin"}, "organization": {"id": 134, "owner": {"id": 58}, "user": {"role": "owner"}}}, "resource": {"id": 170, "owner": {"id": 58}, "organization": {"id": 134}, "project": {"owner": {"id": 489}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 24, "privilege": "admin"}, "organization": {"id": 140, "owner": {"id": 24}, "user": {"role": "owner"}}}, "resource": {"id": 132, "owner": {"id": 24}, "organization": {"id": 315}, "project": {"owner": {"id": 492}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 23, "privilege": "admin"}, "organization": {"id": 124, "owner": {"id": 227}, "user": {"role": "maintainer"}}}, "resource": {"id": 195, "owner": {"id": 23}, "organization": {"id": 124}, "project": {"owner": {"id": 488}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 75, "privilege": "admin"}, "organization": {"id": 197, "owner": {"id": 267}, "user": {"role": "maintainer"}}}, "resource": {"id": 194, "owner": {"id": 75}, "organization": {"id": 335}, "project": {"owner": {"id": 492}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 37, "privilege": "admin"}, "organization": {"id": 129, "owner": {"id": 246}, "user": {"role": "supervisor"}}}, "resource": {"id": 176, "owner": {"id": 37}, "organization": {"id": 129}, "project": {"owner": {"id": 424}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 68, "privilege": "admin"}, "organization": {"id": 116, "owner": {"id": 235}, "user": {"role": "supervisor"}}}, "resource": {"id": 122, "owner": {"id": 68}, "organization": {"id": 301}, "project": {"owner": {"id": 490}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 89, "privilege": "admin"}, "organization": {"id": 116, "owner": {"id": 281}, "user": {"role": "worker"}}}, "resource": {"id": 105, "owner": {"id": 89}, "organization": {"id": 116}, "project": {"owner": {"id": 437}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 73, "privilege": "admin"}, "organization": {"id": 136, "owner": {"id": 260}, "user": {"role": "worker"}}}, "resource": {"id": 196, "owner": {"id": 73}, "organization": {"id": 313}, "project": {"owner": {"id": 401}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 6, "privilege": "admin"}, "organization": {"id": 132, "owner": {"id": 261}, "user": {"role": null}}}, "resource": {"id": 161, "owner": {"id": 6}, "organization": {"id": 132}, "project": {"owner": {"id": 423}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 9, "privilege": "admin"}, "organization": {"id": 173, "owner": {"id": 280}, "user": {"role": null}}}, "resource": {"id": 114, "owner": {"id": 9}, "organization": {"id": 351}, "project": {"owner": {"id": 462}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 72, "privilege": "business"}, "organization": {"id": 138, "owner": {"id": 72}, "user": {"role": "owner"}}}, "resource": {"id": 187, "owner": {"id": 72}, "organization": {"id": 138}, "project": {"owner": {"id": 419}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 53, "privilege": "business"}, "organization": {"id": 177, "owner": {"id": 53}, "user": {"role": "owner"}}}, "resource": {"id": 131, "owner": {"id": 53}, "organization": {"id": 371}, "project": {"owner": {"id": 497}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 48, "privilege": "business"}, "organization": {"id": 157, "owner": {"id": 256}, "user": {"role": "maintainer"}}}, "resource": {"id": 179, "owner": {"id": 48}, "organization": {"id": 157}, "project": {"owner": {"id": 466}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 72, "privilege": "business"}, "organization": {"id": 179, "owner": {"id": 207}, "user": {"role": "maintainer"}}}, "resource": {"id": 138, "owner": {"id": 72}, "organization": {"id": 354}, "project": {"owner": {"id": 439}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 26, "privilege": "business"}, "organization": {"id": 180, "owner": {"id": 227}, "user": {"role": "supervisor"}}}, "resource": {"id": 178, "owner": {"id": 26}, "organization": {"id": 180}, "project": {"owner": {"id": 497}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 30, "privilege": "business"}, "organization": {"id": 122, "owner": {"id": 270}, "user": {"role": "supervisor"}}}, "resource": {"id": 133, "owner": {"id": 30}, "organization": {"id": 310}, "project": {"owner": {"id": 420}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 57, "privilege": "business"}, "organization": {"id": 188, "owner": {"id": 276}, "user": {"role": "worker"}}}, "resource": {"id": 109, "owner": {"id": 57}, "organization": {"id": 188}, "project": {"owner": {"id": 452}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 36, "privilege": "business"}, "organization": {"id": 190, "owner": {"id": 236}, "user": {"role": "worker"}}}, "resource": {"id": 160, "owner": {"id": 36}, "organization": {"id": 304}, "project": {"owner": {"id": 429}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 29, "privilege": "business"}, "organization": {"id": 133, "owner": {"id": 280}, "user": {"role": null}}}, "resource": {"id": 189, "owner": {"id": 29}, "organization": {"id": 133}, "project": {"owner": {"id": 487}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "business"}, "organization": {"id": 169, "owner": {"id": 228}, "user": {"role": null}}}, "resource": {"id": 175, "owner": {"id": 14}, "organization": {"id": 325}, "project": {"owner": {"id": 454}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 9, "privilege": "user"}, "organization": {"id": 107, "owner": {"id": 9}, "user": {"role": "owner"}}}, "resource": {"id": 182, "owner": {"id": 9}, "organization": {"id": 107}, "project": {"owner": {"id": 418}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 36, "privilege": "user"}, "organization": {"id": 156, "owner": {"id": 36}, "user": {"role": "owner"}}}, "resource": {"id": 139, "owner": {"id": 36}, "organization": {"id": 395}, "project": {"owner": {"id": 472}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 51, "privilege": "user"}, "organization": {"id": 134, "owner": {"id": 264}, "user": {"role": "maintainer"}}}, "resource": {"id": 159, "owner": {"id": 51}, "organization": {"id": 134}, "project": {"owner": {"id": 489}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 76, "privilege": "user"}, "organization": {"id": 105, "owner": {"id": 255}, "user": {"role": "maintainer"}}}, "resource": {"id": 169, "owner": {"id": 76}, "organization": {"id": 356}, "project": {"owner": {"id": 410}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 3, "privilege": "user"}, "organization": {"id": 111, "owner": {"id": 229}, "user": {"role": "supervisor"}}}, "resource": {"id": 194, "owner": {"id": 3}, "organization": {"id": 111}, "project": {"owner": {"id": 432}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 97, "privilege": "user"}, "organization": {"id": 186, "owner": {"id": 234}, "user": {"role": "supervisor"}}}, "resource": {"id": 186, "owner": {"id": 97}, "organization": {"id": 375}, "project": {"owner": {"id": 402}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 22, "privilege": "user"}, "organization": {"id": 160, "owner": {"id": 266}, "user": {"role": "worker"}}}, "resource": {"id": 173, "owner": {"id": 22}, "organization": {"id": 160}, "project": {"owner": {"id": 496}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 74, "privilege": "user"}, "organization": {"id": 155, "owner": {"id": 281}, "user": {"role": "worker"}}}, "resource": {"id": 183, "owner": {"id": 74}, "organization": {"id": 335}, "project": {"owner": {"id": 423}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 52, "privilege": "user"}, "organization": {"id": 142, "owner": {"id": 241}, "user": {"role": null}}}, "resource": {"id": 162, "owner": {"id": 52}, "organization": {"id": 142}, "project": {"owner": {"id": 444}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 52, "privilege": "user"}, "organization": {"id": 188, "owner": {"id": 263}, "user": {"role": null}}}, "resource": {"id": 185, "owner": {"id": 52}, "organization": {"id": 320}, "project": {"owner": {"id": 442}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 70, "privilege": "worker"}, "organization": {"id": 104, "owner": {"id": 70}, "user": {"role": "owner"}}}, "resource": {"id": 136, "owner": {"id": 70}, "organization": {"id": 104}, "project": {"owner": {"id": 497}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "worker"}, "organization": {"id": 198, "owner": {"id": 14}, "user": {"role": "owner"}}}, "resource": {"id": 111, "owner": {"id": 14}, "organization": {"id": 332}, "project": {"owner": {"id": 441}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 59, "privilege": "worker"}, "organization": {"id": 152, "owner": {"id": 206}, "user": {"role": "maintainer"}}}, "resource": {"id": 165, "owner": {"id": 59}, "organization": {"id": 152}, "project": {"owner": {"id": 469}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 96, "privilege": "worker"}, "organization": {"id": 163, "owner": {"id": 280}, "user": {"role": "maintainer"}}}, "resource": {"id": 124, "owner": {"id": 96}, "organization": {"id": 346}, "project": {"owner": {"id": 479}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 34, "privilege": "worker"}, "organization": {"id": 170, "owner": {"id": 216}, "user": {"role": "supervisor"}}}, "resource": {"id": 156, "owner": {"id": 34}, "organization": {"id": 170}, "project": {"owner": {"id": 426}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 15, "privilege": "worker"}, "organization": {"id": 103, "owner": {"id": 280}, "user": {"role": "supervisor"}}}, "resource": {"id": 136, "owner": {"id": 15}, "organization": {"id": 389}, "project": {"owner": {"id": 462}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 39, "privilege": "worker"}, "organization": {"id": 170, "owner": {"id": 201}, "user": {"role": "worker"}}}, "resource": {"id": 177, "owner": {"id": 39}, "organization": {"id": 170}, "project": {"owner": {"id": 420}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "worker"}, "organization": {"id": 159, "owner": {"id": 215}, "user": {"role": "worker"}}}, "resource": {"id": 170, "owner": {"id": 14}, "organization": {"id": 311}, "project": {"owner": {"id": 428}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 37, "privilege": "worker"}, "organization": {"id": 165, "owner": {"id": 290}, "user": {"role": null}}}, "resource": {"id": 182, "owner": {"id": 37}, "organization": {"id": 165}, "project": {"owner": {"id": 491}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 31, "privilege": "worker"}, "organization": {"id": 158, "owner": {"id": 270}, "user": {"role": null}}}, "resource": {"id": 134, "owner": {"id": 31}, "organization": {"id": 361}, "project": {"owner": {"id": 460}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 65, "privilege": "none"}, "organization": {"id": 195, "owner": {"id": 65}, "user": {"role": "owner"}}}, "resource": {"id": 118, "owner": {"id": 65}, "organization": {"id": 195}, "project": {"owner": {"id": 476}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 43, "privilege": "none"}, "organization": {"id": 164, "owner": {"id": 43}, "user": {"role": "owner"}}}, "resource": {"id": 108, "owner": {"id": 43}, "organization": {"id": 398}, "project": {"owner": {"id": 453}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 75, "privilege": "none"}, "organization": {"id": 174, "owner": {"id": 284}, "user": {"role": "maintainer"}}}, "resource": {"id": 100, "owner": {"id": 75}, "organization": {"id": 174}, "project": {"owner": {"id": 438}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 61, "privilege": "none"}, "organization": {"id": 144, "owner": {"id": 242}, "user": {"role": "maintainer"}}}, "resource": {"id": 162, "owner": {"id": 61}, "organization": {"id": 357}, "project": {"owner": {"id": 468}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 58, "privilege": "none"}, "organization": {"id": 141, "owner": {"id": 224}, "user": {"role": "supervisor"}}}, "resource": {"id": 170, "owner": {"id": 58}, "organization": {"id": 141}, "project": {"owner": {"id": 448}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 29, "privilege": "none"}, "organization": {"id": 199, "owner": {"id": 252}, "user": {"role": "supervisor"}}}, "resource": {"id": 189, "owner": {"id": 29}, "organization": {"id": 373}, "project": {"owner": {"id": 449}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 90, "privilege": "none"}, "organization": {"id": 148, "owner": {"id": 249}, "user": {"role": "worker"}}}, "resource": {"id": 105, "owner": {"id": 90}, "organization": {"id": 148}, "project": {"owner": {"id": 460}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 4, "privilege": "none"}, "organization": {"id": 116, "owner": {"id": 264}, "user": {"role": "worker"}}}, "resource": {"id": 184, "owner": {"id": 4}, "organization": {"id": 319}, "project": {"owner": {"id": 463}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 12, "privilege": "none"}, "organization": {"id": 167, "owner": {"id": 258}, "user": {"role": null}}}, "resource": {"id": 175, "owner": {"id": 12}, "organization": {"id": 167}, "project": {"owner": {"id": 456}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 83, "privilege": "none"}, "organization": {"id": 119, "owner": {"id": 209}, "user": {"role": null}}}, "resource": {"id": 101, "owner": {"id": 83}, "organization": {"id": 318}, "project": {"owner": {"id": 452}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 88, "privilege": "admin"}, "organization": {"id": 150, "owner": {"id": 88}, "user": {"role": "owner"}}}, "resource": {"id": 160, "owner": {"id": 233}, "organization": {"id": 150}, "project": {"owner": {"id": 479}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 48, "privilege": "admin"}, "organization": {"id": 140, "owner": {"id": 48}, "user": {"role": "owner"}}}, "resource": {"id": 110, "owner": {"id": 242}, "organization": {"id": 386}, "project": {"owner": {"id": 468}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 4, "privilege": "admin"}, "organization": {"id": 179, "owner": {"id": 208}, "user": {"role": "maintainer"}}}, "resource": {"id": 191, "owner": {"id": 297}, "organization": {"id": 179}, "project": {"owner": {"id": 469}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 29, "privilege": "admin"}, "organization": {"id": 195, "owner": {"id": 211}, "user": {"role": "maintainer"}}}, "resource": {"id": 130, "owner": {"id": 280}, "organization": {"id": 387}, "project": {"owner": {"id": 436}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 90, "privilege": "admin"}, "organization": {"id": 112, "owner": {"id": 256}, "user": {"role": "supervisor"}}}, "resource": {"id": 155, "owner": {"id": 212}, "organization": {"id": 112}, "project": {"owner": {"id": 481}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 5, "privilege": "admin"}, "organization": {"id": 141, "owner": {"id": 207}, "user": {"role": "supervisor"}}}, "resource": {"id": 121, "owner": {"id": 288}, "organization": {"id": 338}, "project": {"owner": {"id": 403}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 18, "privilege": "admin"}, "organization": {"id": 131, "owner": {"id": 267}, "user": {"role": "worker"}}}, "resource": {"id": 137, "owner": {"id": 245}, "organization": {"id": 131}, "project": {"owner": {"id": 455}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 21, "privilege": "admin"}, "organization": {"id": 122, "owner": {"id": 210}, "user": {"role": "worker"}}}, "resource": {"id": 152, "owner": {"id": 272}, "organization": {"id": 387}, "project": {"owner": {"id": 423}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 30, "privilege": "admin"}, "organization": {"id": 163, "owner": {"id": 274}, "user": {"role": null}}}, "resource": {"id": 178, "owner": {"id": 248}, "organization": {"id": 163}, "project": {"owner": {"id": 487}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "view", "auth": {"user": {"id": 32, "privilege": "admin"}, "organization": {"id": 158, "owner": {"id": 232}, "user": {"role": null}}}, "resource": {"id": 118, "owner": {"id": 229}, "organization": {"id": 359}, "project": {"owner": {"id": 481}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 86, "privilege": "business"}, "organization": {"id": 169, "owner": {"id": 86}, "user": {"role": "owner"}}}, "resource": {"id": 185, "owner": {"id": 201}, "organization": {"id": 169}, "project": {"owner": {"id": 436}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 38, "privilege": "business"}, "organization": {"id": 181, "owner": {"id": 38}, "user": {"role": "owner"}}}, "resource": {"id": 109, "owner": {"id": 256}, "organization": {"id": 344}, "project": {"owner": {"id": 475}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 25, "privilege": "business"}, "organization": {"id": 149, "owner": {"id": 261}, "user": {"role": "maintainer"}}}, "resource": {"id": 188, "owner": {"id": 232}, "organization": {"id": 149}, "project": {"owner": {"id": 438}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 45, "privilege": "business"}, "organization": {"id": 173, "owner": {"id": 237}, "user": {"role": "maintainer"}}}, "resource": {"id": 113, "owner": {"id": 230}, "organization": {"id": 348}, "project": {"owner": {"id": 473}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 50, "privilege": "business"}, "organization": {"id": 135, "owner": {"id": 201}, "user": {"role": "supervisor"}}}, "resource": {"id": 189, "owner": {"id": 237}, "organization": {"id": 135}, "project": {"owner": {"id": 484}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 6, "privilege": "business"}, "organization": {"id": 177, "owner": {"id": 295}, "user": {"role": "supervisor"}}}, "resource": {"id": 172, "owner": {"id": 287}, "organization": {"id": 399}, "project": {"owner": {"id": 495}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 77, "privilege": "business"}, "organization": {"id": 145, "owner": {"id": 228}, "user": {"role": "worker"}}}, "resource": {"id": 163, "owner": {"id": 236}, "organization": {"id": 145}, "project": {"owner": {"id": 429}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 86, "privilege": "business"}, "organization": {"id": 196, "owner": {"id": 292}, "user": {"role": "worker"}}}, "resource": {"id": 181, "owner": {"id": 224}, "organization": {"id": 379}, "project": {"owner": {"id": 432}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 80, "privilege": "business"}, "organization": {"id": 112, "owner": {"id": 280}, "user": {"role": null}}}, "resource": {"id": 198, "owner": {"id": 284}, "organization": {"id": 112}, "project": {"owner": {"id": 417}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 4, "privilege": "business"}, "organization": {"id": 174, "owner": {"id": 246}, "user": {"role": null}}}, "resource": {"id": 182, "owner": {"id": 205}, "organization": {"id": 339}, "project": {"owner": {"id": 456}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 41, "privilege": "user"}, "organization": {"id": 195, "owner": {"id": 41}, "user": {"role": "owner"}}}, "resource": {"id": 193, "owner": {"id": 216}, "organization": {"id": 195}, "project": {"owner": {"id": 437}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 46, "privilege": "user"}, "organization": {"id": 167, "owner": {"id": 46}, "user": {"role": "owner"}}}, "resource": {"id": 122, "owner": {"id": 225}, "organization": {"id": 316}, "project": {"owner": {"id": 469}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 37, "privilege": "user"}, "organization": {"id": 195, "owner": {"id": 243}, "user": {"role": "maintainer"}}}, "resource": {"id": 134, "owner": {"id": 221}, "organization": {"id": 195}, "project": {"owner": {"id": 461}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 96, "privilege": "user"}, "organization": {"id": 128, "owner": {"id": 286}, "user": {"role": "maintainer"}}}, "resource": {"id": 114, "owner": {"id": 259}, "organization": {"id": 309}, "project": {"owner": {"id": 418}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 46, "privilege": "user"}, "organization": {"id": 111, "owner": {"id": 250}, "user": {"role": "supervisor"}}}, "resource": {"id": 192, "owner": {"id": 286}, "organization": {"id": 111}, "project": {"owner": {"id": 471}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 58, "privilege": "user"}, "organization": {"id": 147, "owner": {"id": 286}, "user": {"role": "supervisor"}}}, "resource": {"id": 101, "owner": {"id": 233}, "organization": {"id": 368}, "project": {"owner": {"id": 415}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 48, "privilege": "user"}, "organization": {"id": 181, "owner": {"id": 247}, "user": {"role": "worker"}}}, "resource": {"id": 195, "owner": {"id": 286}, "organization": {"id": 181}, "project": {"owner": {"id": 474}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 3, "privilege": "user"}, "organization": {"id": 179, "owner": {"id": 271}, "user": {"role": "worker"}}}, "resource": {"id": 113, "owner": {"id": 286}, "organization": {"id": 329}, "project": {"owner": {"id": 460}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 8, "privilege": "user"}, "organization": {"id": 181, "owner": {"id": 259}, "user": {"role": null}}}, "resource": {"id": 141, "owner": {"id": 278}, "organization": {"id": 181}, "project": {"owner": {"id": 482}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "user"}, "organization": {"id": 117, "owner": {"id": 205}, "user": {"role": null}}}, "resource": {"id": 189, "owner": {"id": 238}, "organization": {"id": 383}, "project": {"owner": {"id": 452}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 12, "privilege": "worker"}, "organization": {"id": 130, "owner": {"id": 12}, "user": {"role": "owner"}}}, "resource": {"id": 104, "owner": {"id": 238}, "organization": {"id": 130}, "project": {"owner": {"id": 414}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 85, "privilege": "worker"}, "organization": {"id": 195, "owner": {"id": 85}, "user": {"role": "owner"}}}, "resource": {"id": 117, "owner": {"id": 249}, "organization": {"id": 358}, "project": {"owner": {"id": 447}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "view", "auth": {"user": {"id": 93, "privilege": "worker"}, "organization": {"id": 119, "owner": {"id": 253}, "user": {"role": "maintainer"}}}, "resource": {"id": 169, "owner": {"id": 253}, "organization": {"id": 119}, "project": {"owner": {"id": 495}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 52, "privilege": "worker"}, "organization": {"id": 135, "owner": {"id": 204}, "user": {"role": "maintainer"}}}, "resource": {"id": 183, "owner": {"id": 212}, "organization": {"id": 362}, "project": {"owner": {"id": 478}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 56, "privilege": "worker"}, "organization": {"id": 130, "owner": {"id": 246}, "user": {"role": "supervisor"}}}, "resource": {"id": 188, "owner": {"id": 247}, "organization": {"id": 130}, "project": {"owner": {"id": 456}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 82, "privilege": "worker"}, "organization": {"id": 145, "owner": {"id": 207}, "user": {"role": "supervisor"}}}, "resource": {"id": 112, "owner": {"id": 287}, "organization": {"id": 347}, "project": {"owner": {"id": 469}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 58, "privilege": "worker"}, "organization": {"id": 111, "owner": {"id": 284}, "user": {"role": "worker"}}}, "resource": {"id": 150, "owner": {"id": 235}, "organization": {"id": 111}, "project": {"owner": {"id": 415}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 2, "privilege": "worker"}, "organization": {"id": 106, "owner": {"id": 242}, "user": {"role": "worker"}}}, "resource": {"id": 127, "owner": {"id": 282}, "organization": {"id": 381}, "project": {"owner": {"id": 476}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 8, "privilege": "worker"}, "organization": {"id": 197, "owner": {"id": 270}, "user": {"role": null}}}, "resource": {"id": 131, "owner": {"id": 216}, "organization": {"id": 197}, "project": {"owner": {"id": 426}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 42, "privilege": "worker"}, "organization": {"id": 199, "owner": {"id": 218}, "user": {"role": null}}}, "resource": {"id": 126, "owner": {"id": 275}, "organization": {"id": 327}, "project": {"owner": {"id": 429}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 16, "privilege": "none"}, "organization": {"id": 169, "owner": {"id": 16}, "user": {"role": "owner"}}}, "resource": {"id": 176, "owner": {"id": 200}, "organization": {"id": 169}, "project": {"owner": {"id": 418}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 16, "privilege": "none"}, "organization": {"id": 101, "owner": {"id": 16}, "user": {"role": "owner"}}}, "resource": {"id": 122, "owner": {"id": 214}, "organization": {"id": 384}, "project": {"owner": {"id": 403}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 22, "privilege": "none"}, "organization": {"id": 133, "owner": {"id": 206}, "user": {"role": "maintainer"}}}, "resource": {"id": 130, "owner": {"id": 275}, "organization": {"id": 133}, "project": {"owner": {"id": 402}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 14, "privilege": "none"}, "organization": {"id": 195, "owner": {"id": 208}, "user": {"role": "maintainer"}}}, "resource": {"id": 116, "owner": {"id": 294}, "organization": {"id": 353}, "project": {"owner": {"id": 467}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 65, "privilege": "none"}, "organization": {"id": 175, "owner": {"id": 213}, "user": {"role": "supervisor"}}}, "resource": {"id": 160, "owner": {"id": 257}, "organization": {"id": 175}, "project": {"owner": {"id": 446}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 5, "privilege": "none"}, "organization": {"id": 193, "owner": {"id": 284}, "user": {"role": "supervisor"}}}, "resource": {"id": 157, "owner": {"id": 264}, "organization": {"id": 328}, "project": {"owner": {"id": 478}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 3, "privilege": "none"}, "organization": {"id": 107, "owner": {"id": 261}, "user": {"role": "worker"}}}, "resource": {"id": 166, "owner": {"id": 238}, "organization": {"id": 107}, "project": {"owner": {"id": 482}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 62, "privilege": "none"}, "organization": {"id": 191, "owner": {"id": 256}, "user": {"role": "worker"}}}, "resource": {"id": 151, "owner": {"id": 254}, "organization": {"id": 387}, "project": {"owner": {"id": 413}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 18, "privilege": "none"}, "organization": {"id": 108, "owner": {"id": 216}, "user": {"role": null}}}, "resource": {"id": 109, "owner": {"id": 210}, "organization": {"id": 108}, "project": {"owner": {"id": 477}}}} +} + +test_scope_VIEW_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "view", "auth": {"user": {"id": 70, "privilege": "none"}, "organization": {"id": 191, "owner": {"id": 241}, "user": {"role": null}}}, "resource": {"id": 135, "owner": {"id": 279}, "organization": {"id": 381}, "project": {"owner": {"id": 474}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 98, "privilege": "admin"}, "organization": null}, "resource": {"id": 109, "owner": {"id": 225}, "organization": {"id": 317}, "project": {"owner": {"id": 98}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 22, "privilege": "business"}, "organization": null}, "resource": {"id": 106, "owner": {"id": 286}, "organization": {"id": 338}, "project": {"owner": {"id": 22}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 21, "privilege": "user"}, "organization": null}, "resource": {"id": 115, "owner": {"id": 205}, "organization": {"id": 359}, "project": {"owner": {"id": 21}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 2, "privilege": "worker"}, "organization": null}, "resource": {"id": 167, "owner": {"id": 231}, "organization": {"id": 380}, "project": {"owner": {"id": 2}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 45, "privilege": "none"}, "organization": null}, "resource": {"id": 197, "owner": {"id": 228}, "organization": {"id": 373}, "project": {"owner": {"id": 45}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 41, "privilege": "admin"}, "organization": null}, "resource": {"id": 116, "owner": {"id": 41}, "organization": {"id": 391}, "project": {"owner": {"id": 422}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 93, "privilege": "business"}, "organization": null}, "resource": {"id": 175, "owner": {"id": 93}, "organization": {"id": 348}, "project": {"owner": {"id": 439}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 31, "privilege": "user"}, "organization": null}, "resource": {"id": 157, "owner": {"id": 31}, "organization": {"id": 333}, "project": {"owner": {"id": 426}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 93, "privilege": "worker"}, "organization": null}, "resource": {"id": 143, "owner": {"id": 93}, "organization": {"id": 348}, "project": {"owner": {"id": 420}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 0, "privilege": "none"}, "organization": null}, "resource": {"id": 162, "owner": {"id": 0}, "organization": {"id": 306}, "project": {"owner": {"id": 446}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 72, "privilege": "admin"}, "organization": null}, "resource": {"id": 159, "owner": {"id": 233}, "organization": {"id": 328}, "project": {"owner": {"id": 434}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 22, "privilege": "business"}, "organization": null}, "resource": {"id": 118, "owner": {"id": 215}, "organization": {"id": 396}, "project": {"owner": {"id": 457}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 12, "privilege": "user"}, "organization": null}, "resource": {"id": 172, "owner": {"id": 275}, "organization": {"id": 374}, "project": {"owner": {"id": 497}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 62, "privilege": "worker"}, "organization": null}, "resource": {"id": 143, "owner": {"id": 238}, "organization": {"id": 339}, "project": {"owner": {"id": 435}}}} +} + +test_scope_DELETE_context_SANDBOX_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 25, "privilege": "none"}, "organization": null}, "resource": {"id": 172, "owner": {"id": 203}, "organization": {"id": 322}, "project": {"owner": {"id": 424}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 35, "privilege": "admin"}, "organization": {"id": 147, "owner": {"id": 35}, "user": {"role": "owner"}}}, "resource": {"id": 106, "owner": {"id": 246}, "organization": {"id": 147}, "project": {"owner": {"id": 35}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 87, "privilege": "admin"}, "organization": {"id": 103, "owner": {"id": 87}, "user": {"role": "owner"}}}, "resource": {"id": 164, "owner": {"id": 250}, "organization": {"id": 381}, "project": {"owner": {"id": 87}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 22, "privilege": "admin"}, "organization": {"id": 160, "owner": {"id": 275}, "user": {"role": "maintainer"}}}, "resource": {"id": 147, "owner": {"id": 262}, "organization": {"id": 160}, "project": {"owner": {"id": 22}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 33, "privilege": "admin"}, "organization": {"id": 195, "owner": {"id": 288}, "user": {"role": "maintainer"}}}, "resource": {"id": 170, "owner": {"id": 244}, "organization": {"id": 344}, "project": {"owner": {"id": 33}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 6, "privilege": "admin"}, "organization": {"id": 120, "owner": {"id": 273}, "user": {"role": "supervisor"}}}, "resource": {"id": 111, "owner": {"id": 236}, "organization": {"id": 120}, "project": {"owner": {"id": 6}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 28, "privilege": "admin"}, "organization": {"id": 186, "owner": {"id": 226}, "user": {"role": "supervisor"}}}, "resource": {"id": 199, "owner": {"id": 227}, "organization": {"id": 328}, "project": {"owner": {"id": 28}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 2, "privilege": "admin"}, "organization": {"id": 199, "owner": {"id": 298}, "user": {"role": "worker"}}}, "resource": {"id": 134, "owner": {"id": 283}, "organization": {"id": 199}, "project": {"owner": {"id": 2}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 82, "privilege": "admin"}, "organization": {"id": 122, "owner": {"id": 276}, "user": {"role": "worker"}}}, "resource": {"id": 187, "owner": {"id": 201}, "organization": {"id": 360}, "project": {"owner": {"id": 82}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 39, "privilege": "admin"}, "organization": {"id": 191, "owner": {"id": 290}, "user": {"role": null}}}, "resource": {"id": 101, "owner": {"id": 228}, "organization": {"id": 191}, "project": {"owner": {"id": 39}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 44, "privilege": "admin"}, "organization": {"id": 158, "owner": {"id": 232}, "user": {"role": null}}}, "resource": {"id": 182, "owner": {"id": 235}, "organization": {"id": 354}, "project": {"owner": {"id": 44}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 66, "privilege": "business"}, "organization": {"id": 179, "owner": {"id": 66}, "user": {"role": "owner"}}}, "resource": {"id": 127, "owner": {"id": 259}, "organization": {"id": 179}, "project": {"owner": {"id": 66}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 84, "privilege": "business"}, "organization": {"id": 147, "owner": {"id": 84}, "user": {"role": "owner"}}}, "resource": {"id": 174, "owner": {"id": 213}, "organization": {"id": 300}, "project": {"owner": {"id": 84}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 38, "privilege": "business"}, "organization": {"id": 187, "owner": {"id": 213}, "user": {"role": "maintainer"}}}, "resource": {"id": 182, "owner": {"id": 276}, "organization": {"id": 187}, "project": {"owner": {"id": 38}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 81, "privilege": "business"}, "organization": {"id": 141, "owner": {"id": 235}, "user": {"role": "maintainer"}}}, "resource": {"id": 161, "owner": {"id": 208}, "organization": {"id": 343}, "project": {"owner": {"id": 81}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 38, "privilege": "business"}, "organization": {"id": 124, "owner": {"id": 219}, "user": {"role": "supervisor"}}}, "resource": {"id": 133, "owner": {"id": 283}, "organization": {"id": 124}, "project": {"owner": {"id": 38}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 76, "privilege": "business"}, "organization": {"id": 151, "owner": {"id": 284}, "user": {"role": "supervisor"}}}, "resource": {"id": 166, "owner": {"id": 297}, "organization": {"id": 330}, "project": {"owner": {"id": 76}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 3, "privilege": "business"}, "organization": {"id": 183, "owner": {"id": 263}, "user": {"role": "worker"}}}, "resource": {"id": 140, "owner": {"id": 287}, "organization": {"id": 183}, "project": {"owner": {"id": 3}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 49, "privilege": "business"}, "organization": {"id": 194, "owner": {"id": 204}, "user": {"role": "worker"}}}, "resource": {"id": 137, "owner": {"id": 233}, "organization": {"id": 353}, "project": {"owner": {"id": 49}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 43, "privilege": "business"}, "organization": {"id": 192, "owner": {"id": 291}, "user": {"role": null}}}, "resource": {"id": 174, "owner": {"id": 290}, "organization": {"id": 192}, "project": {"owner": {"id": 43}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 60, "privilege": "business"}, "organization": {"id": 185, "owner": {"id": 245}, "user": {"role": null}}}, "resource": {"id": 128, "owner": {"id": 287}, "organization": {"id": 368}, "project": {"owner": {"id": 60}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 71, "privilege": "user"}, "organization": {"id": 123, "owner": {"id": 71}, "user": {"role": "owner"}}}, "resource": {"id": 164, "owner": {"id": 238}, "organization": {"id": 123}, "project": {"owner": {"id": 71}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 33, "privilege": "user"}, "organization": {"id": 192, "owner": {"id": 33}, "user": {"role": "owner"}}}, "resource": {"id": 112, "owner": {"id": 260}, "organization": {"id": 316}, "project": {"owner": {"id": 33}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 83, "privilege": "user"}, "organization": {"id": 111, "owner": {"id": 228}, "user": {"role": "maintainer"}}}, "resource": {"id": 197, "owner": {"id": 223}, "organization": {"id": 111}, "project": {"owner": {"id": 83}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 53, "privilege": "user"}, "organization": {"id": 142, "owner": {"id": 247}, "user": {"role": "maintainer"}}}, "resource": {"id": 145, "owner": {"id": 228}, "organization": {"id": 395}, "project": {"owner": {"id": 53}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 15, "privilege": "user"}, "organization": {"id": 160, "owner": {"id": 206}, "user": {"role": "supervisor"}}}, "resource": {"id": 133, "owner": {"id": 274}, "organization": {"id": 160}, "project": {"owner": {"id": 15}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 9, "privilege": "user"}, "organization": {"id": 160, "owner": {"id": 224}, "user": {"role": "supervisor"}}}, "resource": {"id": 181, "owner": {"id": 274}, "organization": {"id": 374}, "project": {"owner": {"id": 9}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 67, "privilege": "user"}, "organization": {"id": 138, "owner": {"id": 252}, "user": {"role": "worker"}}}, "resource": {"id": 164, "owner": {"id": 215}, "organization": {"id": 138}, "project": {"owner": {"id": 67}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 43, "privilege": "user"}, "organization": {"id": 152, "owner": {"id": 272}, "user": {"role": "worker"}}}, "resource": {"id": 106, "owner": {"id": 219}, "organization": {"id": 317}, "project": {"owner": {"id": 43}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 23, "privilege": "user"}, "organization": {"id": 199, "owner": {"id": 210}, "user": {"role": null}}}, "resource": {"id": 158, "owner": {"id": 218}, "organization": {"id": 199}, "project": {"owner": {"id": 23}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 40, "privilege": "user"}, "organization": {"id": 181, "owner": {"id": 207}, "user": {"role": null}}}, "resource": {"id": 162, "owner": {"id": 242}, "organization": {"id": 380}, "project": {"owner": {"id": 40}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 97, "privilege": "worker"}, "organization": {"id": 199, "owner": {"id": 97}, "user": {"role": "owner"}}}, "resource": {"id": 100, "owner": {"id": 257}, "organization": {"id": 199}, "project": {"owner": {"id": 97}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 97, "privilege": "worker"}, "organization": {"id": 199, "owner": {"id": 97}, "user": {"role": "owner"}}}, "resource": {"id": 173, "owner": {"id": 297}, "organization": {"id": 320}, "project": {"owner": {"id": 97}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 54, "privilege": "worker"}, "organization": {"id": 151, "owner": {"id": 254}, "user": {"role": "maintainer"}}}, "resource": {"id": 116, "owner": {"id": 278}, "organization": {"id": 151}, "project": {"owner": {"id": 54}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 68, "privilege": "worker"}, "organization": {"id": 125, "owner": {"id": 293}, "user": {"role": "maintainer"}}}, "resource": {"id": 161, "owner": {"id": 249}, "organization": {"id": 300}, "project": {"owner": {"id": 68}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 67, "privilege": "worker"}, "organization": {"id": 124, "owner": {"id": 202}, "user": {"role": "supervisor"}}}, "resource": {"id": 147, "owner": {"id": 201}, "organization": {"id": 124}, "project": {"owner": {"id": 67}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 31, "privilege": "worker"}, "organization": {"id": 128, "owner": {"id": 288}, "user": {"role": "supervisor"}}}, "resource": {"id": 186, "owner": {"id": 200}, "organization": {"id": 380}, "project": {"owner": {"id": 31}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 49, "privilege": "worker"}, "organization": {"id": 164, "owner": {"id": 275}, "user": {"role": "worker"}}}, "resource": {"id": 144, "owner": {"id": 239}, "organization": {"id": 164}, "project": {"owner": {"id": 49}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 38, "privilege": "worker"}, "organization": {"id": 137, "owner": {"id": 258}, "user": {"role": "worker"}}}, "resource": {"id": 139, "owner": {"id": 221}, "organization": {"id": 308}, "project": {"owner": {"id": 38}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 43, "privilege": "worker"}, "organization": {"id": 155, "owner": {"id": 287}, "user": {"role": null}}}, "resource": {"id": 193, "owner": {"id": 266}, "organization": {"id": 155}, "project": {"owner": {"id": 43}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 96, "privilege": "worker"}, "organization": {"id": 124, "owner": {"id": 221}, "user": {"role": null}}}, "resource": {"id": 117, "owner": {"id": 243}, "organization": {"id": 362}, "project": {"owner": {"id": 96}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 93, "privilege": "none"}, "organization": {"id": 116, "owner": {"id": 93}, "user": {"role": "owner"}}}, "resource": {"id": 151, "owner": {"id": 202}, "organization": {"id": 116}, "project": {"owner": {"id": 93}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 21, "privilege": "none"}, "organization": {"id": 115, "owner": {"id": 21}, "user": {"role": "owner"}}}, "resource": {"id": 193, "owner": {"id": 202}, "organization": {"id": 375}, "project": {"owner": {"id": 21}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 81, "privilege": "none"}, "organization": {"id": 132, "owner": {"id": 298}, "user": {"role": "maintainer"}}}, "resource": {"id": 191, "owner": {"id": 283}, "organization": {"id": 132}, "project": {"owner": {"id": 81}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 6, "privilege": "none"}, "organization": {"id": 184, "owner": {"id": 213}, "user": {"role": "maintainer"}}}, "resource": {"id": 168, "owner": {"id": 279}, "organization": {"id": 306}, "project": {"owner": {"id": 6}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 14, "privilege": "none"}, "organization": {"id": 153, "owner": {"id": 256}, "user": {"role": "supervisor"}}}, "resource": {"id": 183, "owner": {"id": 202}, "organization": {"id": 153}, "project": {"owner": {"id": 14}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 60, "privilege": "none"}, "organization": {"id": 189, "owner": {"id": 219}, "user": {"role": "supervisor"}}}, "resource": {"id": 148, "owner": {"id": 215}, "organization": {"id": 370}, "project": {"owner": {"id": 60}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 1, "privilege": "none"}, "organization": {"id": 138, "owner": {"id": 253}, "user": {"role": "worker"}}}, "resource": {"id": 126, "owner": {"id": 288}, "organization": {"id": 138}, "project": {"owner": {"id": 1}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 34, "privilege": "none"}, "organization": {"id": 179, "owner": {"id": 277}, "user": {"role": "worker"}}}, "resource": {"id": 184, "owner": {"id": 212}, "organization": {"id": 384}, "project": {"owner": {"id": 34}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 65, "privilege": "none"}, "organization": {"id": 192, "owner": {"id": 279}, "user": {"role": null}}}, "resource": {"id": 189, "owner": {"id": 217}, "organization": {"id": 192}, "project": {"owner": {"id": 65}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 63, "privilege": "none"}, "organization": {"id": 125, "owner": {"id": 277}, "user": {"role": null}}}, "resource": {"id": 115, "owner": {"id": 236}, "organization": {"id": 314}, "project": {"owner": {"id": 63}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 25, "privilege": "admin"}, "organization": {"id": 145, "owner": {"id": 25}, "user": {"role": "owner"}}}, "resource": {"id": 125, "owner": {"id": 25}, "organization": {"id": 145}, "project": {"owner": {"id": 466}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 70, "privilege": "admin"}, "organization": {"id": 163, "owner": {"id": 70}, "user": {"role": "owner"}}}, "resource": {"id": 152, "owner": {"id": 70}, "organization": {"id": 320}, "project": {"owner": {"id": 405}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 0, "privilege": "admin"}, "organization": {"id": 101, "owner": {"id": 285}, "user": {"role": "maintainer"}}}, "resource": {"id": 188, "owner": {"id": 0}, "organization": {"id": 101}, "project": {"owner": {"id": 430}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 63, "privilege": "admin"}, "organization": {"id": 119, "owner": {"id": 211}, "user": {"role": "maintainer"}}}, "resource": {"id": 111, "owner": {"id": 63}, "organization": {"id": 373}, "project": {"owner": {"id": 485}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 92, "privilege": "admin"}, "organization": {"id": 112, "owner": {"id": 232}, "user": {"role": "supervisor"}}}, "resource": {"id": 199, "owner": {"id": 92}, "organization": {"id": 112}, "project": {"owner": {"id": 492}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 33, "privilege": "admin"}, "organization": {"id": 159, "owner": {"id": 206}, "user": {"role": "supervisor"}}}, "resource": {"id": 182, "owner": {"id": 33}, "organization": {"id": 358}, "project": {"owner": {"id": 437}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 46, "privilege": "admin"}, "organization": {"id": 186, "owner": {"id": 240}, "user": {"role": "worker"}}}, "resource": {"id": 112, "owner": {"id": 46}, "organization": {"id": 186}, "project": {"owner": {"id": 437}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 97, "privilege": "admin"}, "organization": {"id": 105, "owner": {"id": 201}, "user": {"role": "worker"}}}, "resource": {"id": 154, "owner": {"id": 97}, "organization": {"id": 314}, "project": {"owner": {"id": 412}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 42, "privilege": "admin"}, "organization": {"id": 145, "owner": {"id": 256}, "user": {"role": null}}}, "resource": {"id": 117, "owner": {"id": 42}, "organization": {"id": 145}, "project": {"owner": {"id": 421}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 47, "privilege": "admin"}, "organization": {"id": 143, "owner": {"id": 223}, "user": {"role": null}}}, "resource": {"id": 179, "owner": {"id": 47}, "organization": {"id": 393}, "project": {"owner": {"id": 411}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 34, "privilege": "business"}, "organization": {"id": 149, "owner": {"id": 34}, "user": {"role": "owner"}}}, "resource": {"id": 114, "owner": {"id": 34}, "organization": {"id": 149}, "project": {"owner": {"id": 458}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 14, "privilege": "business"}, "organization": {"id": 116, "owner": {"id": 14}, "user": {"role": "owner"}}}, "resource": {"id": 161, "owner": {"id": 14}, "organization": {"id": 383}, "project": {"owner": {"id": 421}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 53, "privilege": "business"}, "organization": {"id": 175, "owner": {"id": 261}, "user": {"role": "maintainer"}}}, "resource": {"id": 192, "owner": {"id": 53}, "organization": {"id": 175}, "project": {"owner": {"id": 413}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 78, "privilege": "business"}, "organization": {"id": 148, "owner": {"id": 299}, "user": {"role": "maintainer"}}}, "resource": {"id": 172, "owner": {"id": 78}, "organization": {"id": 356}, "project": {"owner": {"id": 423}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 93, "privilege": "business"}, "organization": {"id": 116, "owner": {"id": 261}, "user": {"role": "supervisor"}}}, "resource": {"id": 145, "owner": {"id": 93}, "organization": {"id": 116}, "project": {"owner": {"id": 487}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 5, "privilege": "business"}, "organization": {"id": 108, "owner": {"id": 233}, "user": {"role": "supervisor"}}}, "resource": {"id": 162, "owner": {"id": 5}, "organization": {"id": 352}, "project": {"owner": {"id": 456}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 86, "privilege": "business"}, "organization": {"id": 167, "owner": {"id": 298}, "user": {"role": "worker"}}}, "resource": {"id": 183, "owner": {"id": 86}, "organization": {"id": 167}, "project": {"owner": {"id": 490}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 28, "privilege": "business"}, "organization": {"id": 185, "owner": {"id": 243}, "user": {"role": "worker"}}}, "resource": {"id": 195, "owner": {"id": 28}, "organization": {"id": 392}, "project": {"owner": {"id": 472}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 79, "privilege": "business"}, "organization": {"id": 112, "owner": {"id": 255}, "user": {"role": null}}}, "resource": {"id": 166, "owner": {"id": 79}, "organization": {"id": 112}, "project": {"owner": {"id": 489}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 44, "privilege": "business"}, "organization": {"id": 184, "owner": {"id": 286}, "user": {"role": null}}}, "resource": {"id": 184, "owner": {"id": 44}, "organization": {"id": 331}, "project": {"owner": {"id": 461}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 78, "privilege": "user"}, "organization": {"id": 106, "owner": {"id": 78}, "user": {"role": "owner"}}}, "resource": {"id": 182, "owner": {"id": 78}, "organization": {"id": 106}, "project": {"owner": {"id": 471}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 63, "privilege": "user"}, "organization": {"id": 121, "owner": {"id": 63}, "user": {"role": "owner"}}}, "resource": {"id": 182, "owner": {"id": 63}, "organization": {"id": 364}, "project": {"owner": {"id": 460}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 42, "privilege": "user"}, "organization": {"id": 135, "owner": {"id": 206}, "user": {"role": "maintainer"}}}, "resource": {"id": 196, "owner": {"id": 42}, "organization": {"id": 135}, "project": {"owner": {"id": 430}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 48, "privilege": "user"}, "organization": {"id": 151, "owner": {"id": 230}, "user": {"role": "maintainer"}}}, "resource": {"id": 164, "owner": {"id": 48}, "organization": {"id": 370}, "project": {"owner": {"id": 482}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 57, "privilege": "user"}, "organization": {"id": 111, "owner": {"id": 263}, "user": {"role": "supervisor"}}}, "resource": {"id": 110, "owner": {"id": 57}, "organization": {"id": 111}, "project": {"owner": {"id": 473}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 95, "privilege": "user"}, "organization": {"id": 183, "owner": {"id": 202}, "user": {"role": "supervisor"}}}, "resource": {"id": 157, "owner": {"id": 95}, "organization": {"id": 315}, "project": {"owner": {"id": 463}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 71, "privilege": "user"}, "organization": {"id": 170, "owner": {"id": 200}, "user": {"role": "worker"}}}, "resource": {"id": 113, "owner": {"id": 71}, "organization": {"id": 170}, "project": {"owner": {"id": 404}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 38, "privilege": "user"}, "organization": {"id": 165, "owner": {"id": 271}, "user": {"role": "worker"}}}, "resource": {"id": 111, "owner": {"id": 38}, "organization": {"id": 379}, "project": {"owner": {"id": 481}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 43, "privilege": "user"}, "organization": {"id": 146, "owner": {"id": 206}, "user": {"role": null}}}, "resource": {"id": 126, "owner": {"id": 43}, "organization": {"id": 146}, "project": {"owner": {"id": 458}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 98, "privilege": "user"}, "organization": {"id": 179, "owner": {"id": 276}, "user": {"role": null}}}, "resource": {"id": 128, "owner": {"id": 98}, "organization": {"id": 342}, "project": {"owner": {"id": 471}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 91, "privilege": "worker"}, "organization": {"id": 157, "owner": {"id": 91}, "user": {"role": "owner"}}}, "resource": {"id": 160, "owner": {"id": 91}, "organization": {"id": 157}, "project": {"owner": {"id": 446}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 0, "privilege": "worker"}, "organization": {"id": 143, "owner": {"id": 0}, "user": {"role": "owner"}}}, "resource": {"id": 112, "owner": {"id": 0}, "organization": {"id": 384}, "project": {"owner": {"id": 428}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 47, "privilege": "worker"}, "organization": {"id": 189, "owner": {"id": 213}, "user": {"role": "maintainer"}}}, "resource": {"id": 181, "owner": {"id": 47}, "organization": {"id": 189}, "project": {"owner": {"id": 466}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 91, "privilege": "worker"}, "organization": {"id": 195, "owner": {"id": 292}, "user": {"role": "maintainer"}}}, "resource": {"id": 105, "owner": {"id": 91}, "organization": {"id": 363}, "project": {"owner": {"id": 417}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 25, "privilege": "worker"}, "organization": {"id": 124, "owner": {"id": 215}, "user": {"role": "supervisor"}}}, "resource": {"id": 152, "owner": {"id": 25}, "organization": {"id": 124}, "project": {"owner": {"id": 478}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 91, "privilege": "worker"}, "organization": {"id": 143, "owner": {"id": 213}, "user": {"role": "supervisor"}}}, "resource": {"id": 150, "owner": {"id": 91}, "organization": {"id": 358}, "project": {"owner": {"id": 424}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 86, "privilege": "worker"}, "organization": {"id": 175, "owner": {"id": 215}, "user": {"role": "worker"}}}, "resource": {"id": 196, "owner": {"id": 86}, "organization": {"id": 175}, "project": {"owner": {"id": 481}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 75, "privilege": "worker"}, "organization": {"id": 164, "owner": {"id": 217}, "user": {"role": "worker"}}}, "resource": {"id": 198, "owner": {"id": 75}, "organization": {"id": 358}, "project": {"owner": {"id": 484}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 6, "privilege": "worker"}, "organization": {"id": 170, "owner": {"id": 255}, "user": {"role": null}}}, "resource": {"id": 163, "owner": {"id": 6}, "organization": {"id": 170}, "project": {"owner": {"id": 494}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 22, "privilege": "worker"}, "organization": {"id": 188, "owner": {"id": 274}, "user": {"role": null}}}, "resource": {"id": 174, "owner": {"id": 22}, "organization": {"id": 365}, "project": {"owner": {"id": 499}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 49, "privilege": "none"}, "organization": {"id": 184, "owner": {"id": 49}, "user": {"role": "owner"}}}, "resource": {"id": 122, "owner": {"id": 49}, "organization": {"id": 184}, "project": {"owner": {"id": 413}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 53, "privilege": "none"}, "organization": {"id": 177, "owner": {"id": 53}, "user": {"role": "owner"}}}, "resource": {"id": 177, "owner": {"id": 53}, "organization": {"id": 364}, "project": {"owner": {"id": 449}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 43, "privilege": "none"}, "organization": {"id": 137, "owner": {"id": 257}, "user": {"role": "maintainer"}}}, "resource": {"id": 197, "owner": {"id": 43}, "organization": {"id": 137}, "project": {"owner": {"id": 450}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 89, "privilege": "none"}, "organization": {"id": 195, "owner": {"id": 265}, "user": {"role": "maintainer"}}}, "resource": {"id": 117, "owner": {"id": 89}, "organization": {"id": 352}, "project": {"owner": {"id": 477}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 91, "privilege": "none"}, "organization": {"id": 194, "owner": {"id": 270}, "user": {"role": "supervisor"}}}, "resource": {"id": 138, "owner": {"id": 91}, "organization": {"id": 194}, "project": {"owner": {"id": 440}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 79, "privilege": "none"}, "organization": {"id": 191, "owner": {"id": 237}, "user": {"role": "supervisor"}}}, "resource": {"id": 180, "owner": {"id": 79}, "organization": {"id": 325}, "project": {"owner": {"id": 426}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 90, "privilege": "none"}, "organization": {"id": 183, "owner": {"id": 222}, "user": {"role": "worker"}}}, "resource": {"id": 187, "owner": {"id": 90}, "organization": {"id": 183}, "project": {"owner": {"id": 416}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 41, "privilege": "none"}, "organization": {"id": 190, "owner": {"id": 214}, "user": {"role": "worker"}}}, "resource": {"id": 164, "owner": {"id": 41}, "organization": {"id": 388}, "project": {"owner": {"id": 481}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 74, "privilege": "none"}, "organization": {"id": 173, "owner": {"id": 285}, "user": {"role": null}}}, "resource": {"id": 197, "owner": {"id": 74}, "organization": {"id": 173}, "project": {"owner": {"id": 461}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 68, "privilege": "none"}, "organization": {"id": 136, "owner": {"id": 254}, "user": {"role": null}}}, "resource": {"id": 190, "owner": {"id": 68}, "organization": {"id": 386}, "project": {"owner": {"id": 495}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 48, "privilege": "admin"}, "organization": {"id": 118, "owner": {"id": 48}, "user": {"role": "owner"}}}, "resource": {"id": 101, "owner": {"id": 266}, "organization": {"id": 118}, "project": {"owner": {"id": 403}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 96, "privilege": "admin"}, "organization": {"id": 120, "owner": {"id": 96}, "user": {"role": "owner"}}}, "resource": {"id": 185, "owner": {"id": 207}, "organization": {"id": 325}, "project": {"owner": {"id": 434}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 88, "privilege": "admin"}, "organization": {"id": 137, "owner": {"id": 226}, "user": {"role": "maintainer"}}}, "resource": {"id": 187, "owner": {"id": 232}, "organization": {"id": 137}, "project": {"owner": {"id": 407}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 57, "privilege": "admin"}, "organization": {"id": 113, "owner": {"id": 278}, "user": {"role": "maintainer"}}}, "resource": {"id": 196, "owner": {"id": 269}, "organization": {"id": 304}, "project": {"owner": {"id": 445}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 95, "privilege": "admin"}, "organization": {"id": 182, "owner": {"id": 271}, "user": {"role": "supervisor"}}}, "resource": {"id": 186, "owner": {"id": 294}, "organization": {"id": 182}, "project": {"owner": {"id": 428}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 36, "privilege": "admin"}, "organization": {"id": 188, "owner": {"id": 285}, "user": {"role": "supervisor"}}}, "resource": {"id": 149, "owner": {"id": 230}, "organization": {"id": 365}, "project": {"owner": {"id": 489}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 87, "privilege": "admin"}, "organization": {"id": 140, "owner": {"id": 270}, "user": {"role": "worker"}}}, "resource": {"id": 106, "owner": {"id": 248}, "organization": {"id": 140}, "project": {"owner": {"id": 453}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 25, "privilege": "admin"}, "organization": {"id": 179, "owner": {"id": 299}, "user": {"role": "worker"}}}, "resource": {"id": 106, "owner": {"id": 201}, "organization": {"id": 395}, "project": {"owner": {"id": 432}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 90, "privilege": "admin"}, "organization": {"id": 182, "owner": {"id": 247}, "user": {"role": null}}}, "resource": {"id": 156, "owner": {"id": 228}, "organization": {"id": 182}, "project": {"owner": {"id": 490}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 77, "privilege": "admin"}, "organization": {"id": 125, "owner": {"id": 299}, "user": {"role": null}}}, "resource": {"id": 196, "owner": {"id": 275}, "organization": {"id": 369}, "project": {"owner": {"id": 494}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 22, "privilege": "business"}, "organization": {"id": 187, "owner": {"id": 22}, "user": {"role": "owner"}}}, "resource": {"id": 192, "owner": {"id": 224}, "organization": {"id": 187}, "project": {"owner": {"id": 457}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 15, "privilege": "business"}, "organization": {"id": 148, "owner": {"id": 15}, "user": {"role": "owner"}}}, "resource": {"id": 123, "owner": {"id": 222}, "organization": {"id": 394}, "project": {"owner": {"id": 465}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 16, "privilege": "business"}, "organization": {"id": 120, "owner": {"id": 274}, "user": {"role": "maintainer"}}}, "resource": {"id": 154, "owner": {"id": 235}, "organization": {"id": 120}, "project": {"owner": {"id": 433}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 90, "privilege": "business"}, "organization": {"id": 119, "owner": {"id": 205}, "user": {"role": "maintainer"}}}, "resource": {"id": 132, "owner": {"id": 200}, "organization": {"id": 342}, "project": {"owner": {"id": 459}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 99, "privilege": "business"}, "organization": {"id": 180, "owner": {"id": 278}, "user": {"role": "supervisor"}}}, "resource": {"id": 119, "owner": {"id": 241}, "organization": {"id": 180}, "project": {"owner": {"id": 406}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 70, "privilege": "business"}, "organization": {"id": 145, "owner": {"id": 209}, "user": {"role": "supervisor"}}}, "resource": {"id": 184, "owner": {"id": 238}, "organization": {"id": 362}, "project": {"owner": {"id": 473}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 23, "privilege": "business"}, "organization": {"id": 166, "owner": {"id": 208}, "user": {"role": "worker"}}}, "resource": {"id": 190, "owner": {"id": 240}, "organization": {"id": 166}, "project": {"owner": {"id": 428}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 68, "privilege": "business"}, "organization": {"id": 151, "owner": {"id": 211}, "user": {"role": "worker"}}}, "resource": {"id": 164, "owner": {"id": 220}, "organization": {"id": 353}, "project": {"owner": {"id": 469}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 42, "privilege": "business"}, "organization": {"id": 142, "owner": {"id": 246}, "user": {"role": null}}}, "resource": {"id": 144, "owner": {"id": 228}, "organization": {"id": 142}, "project": {"owner": {"id": 484}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 67, "privilege": "business"}, "organization": {"id": 160, "owner": {"id": 271}, "user": {"role": null}}}, "resource": {"id": 137, "owner": {"id": 227}, "organization": {"id": 396}, "project": {"owner": {"id": 479}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 85, "privilege": "user"}, "organization": {"id": 144, "owner": {"id": 85}, "user": {"role": "owner"}}}, "resource": {"id": 196, "owner": {"id": 283}, "organization": {"id": 144}, "project": {"owner": {"id": 414}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 31, "privilege": "user"}, "organization": {"id": 105, "owner": {"id": 31}, "user": {"role": "owner"}}}, "resource": {"id": 130, "owner": {"id": 282}, "organization": {"id": 378}, "project": {"owner": {"id": 479}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 32, "privilege": "user"}, "organization": {"id": 169, "owner": {"id": 292}, "user": {"role": "maintainer"}}}, "resource": {"id": 141, "owner": {"id": 248}, "organization": {"id": 169}, "project": {"owner": {"id": 449}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 47, "privilege": "user"}, "organization": {"id": 165, "owner": {"id": 265}, "user": {"role": "maintainer"}}}, "resource": {"id": 136, "owner": {"id": 203}, "organization": {"id": 366}, "project": {"owner": {"id": 498}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 83, "privilege": "user"}, "organization": {"id": 124, "owner": {"id": 241}, "user": {"role": "supervisor"}}}, "resource": {"id": 157, "owner": {"id": 262}, "organization": {"id": 124}, "project": {"owner": {"id": 437}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 98, "privilege": "user"}, "organization": {"id": 168, "owner": {"id": 268}, "user": {"role": "supervisor"}}}, "resource": {"id": 165, "owner": {"id": 210}, "organization": {"id": 312}, "project": {"owner": {"id": 421}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 85, "privilege": "user"}, "organization": {"id": 180, "owner": {"id": 299}, "user": {"role": "worker"}}}, "resource": {"id": 100, "owner": {"id": 280}, "organization": {"id": 180}, "project": {"owner": {"id": 426}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 26, "privilege": "user"}, "organization": {"id": 168, "owner": {"id": 289}, "user": {"role": "worker"}}}, "resource": {"id": 127, "owner": {"id": 287}, "organization": {"id": 354}, "project": {"owner": {"id": 413}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 9, "privilege": "user"}, "organization": {"id": 195, "owner": {"id": 287}, "user": {"role": null}}}, "resource": {"id": 194, "owner": {"id": 299}, "organization": {"id": 195}, "project": {"owner": {"id": 482}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 42, "privilege": "user"}, "organization": {"id": 104, "owner": {"id": 211}, "user": {"role": null}}}, "resource": {"id": 119, "owner": {"id": 203}, "organization": {"id": 358}, "project": {"owner": {"id": 491}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 71, "privilege": "worker"}, "organization": {"id": 109, "owner": {"id": 71}, "user": {"role": "owner"}}}, "resource": {"id": 109, "owner": {"id": 206}, "organization": {"id": 109}, "project": {"owner": {"id": 432}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 57, "privilege": "worker"}, "organization": {"id": 180, "owner": {"id": 57}, "user": {"role": "owner"}}}, "resource": {"id": 129, "owner": {"id": 233}, "organization": {"id": 352}, "project": {"owner": {"id": 449}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "delete", "auth": {"user": {"id": 86, "privilege": "worker"}, "organization": {"id": 115, "owner": {"id": 268}, "user": {"role": "maintainer"}}}, "resource": {"id": 155, "owner": {"id": 240}, "organization": {"id": 115}, "project": {"owner": {"id": 449}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 8, "privilege": "worker"}, "organization": {"id": 196, "owner": {"id": 295}, "user": {"role": "maintainer"}}}, "resource": {"id": 100, "owner": {"id": 282}, "organization": {"id": 392}, "project": {"owner": {"id": 479}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 45, "privilege": "worker"}, "organization": {"id": 164, "owner": {"id": 213}, "user": {"role": "supervisor"}}}, "resource": {"id": 174, "owner": {"id": 205}, "organization": {"id": 164}, "project": {"owner": {"id": 409}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 11, "privilege": "worker"}, "organization": {"id": 136, "owner": {"id": 297}, "user": {"role": "supervisor"}}}, "resource": {"id": 137, "owner": {"id": 287}, "organization": {"id": 338}, "project": {"owner": {"id": 477}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 3, "privilege": "worker"}, "organization": {"id": 160, "owner": {"id": 220}, "user": {"role": "worker"}}}, "resource": {"id": 156, "owner": {"id": 248}, "organization": {"id": 160}, "project": {"owner": {"id": 484}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 50, "privilege": "worker"}, "organization": {"id": 169, "owner": {"id": 237}, "user": {"role": "worker"}}}, "resource": {"id": 168, "owner": {"id": 228}, "organization": {"id": 317}, "project": {"owner": {"id": 495}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 93, "privilege": "worker"}, "organization": {"id": 182, "owner": {"id": 247}, "user": {"role": null}}}, "resource": {"id": 181, "owner": {"id": 218}, "organization": {"id": 182}, "project": {"owner": {"id": 486}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 15, "privilege": "worker"}, "organization": {"id": 105, "owner": {"id": 200}, "user": {"role": null}}}, "resource": {"id": 101, "owner": {"id": 270}, "organization": {"id": 371}, "project": {"owner": {"id": 418}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 70, "privilege": "none"}, "organization": {"id": 110, "owner": {"id": 70}, "user": {"role": "owner"}}}, "resource": {"id": 173, "owner": {"id": 276}, "organization": {"id": 110}, "project": {"owner": {"id": 468}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 44, "privilege": "none"}, "organization": {"id": 127, "owner": {"id": 44}, "user": {"role": "owner"}}}, "resource": {"id": 126, "owner": {"id": 295}, "organization": {"id": 394}, "project": {"owner": {"id": 492}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 23, "privilege": "none"}, "organization": {"id": 128, "owner": {"id": 278}, "user": {"role": "maintainer"}}}, "resource": {"id": 182, "owner": {"id": 265}, "organization": {"id": 128}, "project": {"owner": {"id": 420}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 85, "privilege": "none"}, "organization": {"id": 172, "owner": {"id": 206}, "user": {"role": "maintainer"}}}, "resource": {"id": 132, "owner": {"id": 224}, "organization": {"id": 314}, "project": {"owner": {"id": 423}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 70, "privilege": "none"}, "organization": {"id": 109, "owner": {"id": 237}, "user": {"role": "supervisor"}}}, "resource": {"id": 196, "owner": {"id": 270}, "organization": {"id": 109}, "project": {"owner": {"id": 482}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 25, "privilege": "none"}, "organization": {"id": 192, "owner": {"id": 274}, "user": {"role": "supervisor"}}}, "resource": {"id": 185, "owner": {"id": 208}, "organization": {"id": 332}, "project": {"owner": {"id": 412}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 85, "privilege": "none"}, "organization": {"id": 183, "owner": {"id": 230}, "user": {"role": "worker"}}}, "resource": {"id": 162, "owner": {"id": 242}, "organization": {"id": 183}, "project": {"owner": {"id": 416}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 24, "privilege": "none"}, "organization": {"id": 140, "owner": {"id": 262}, "user": {"role": "worker"}}}, "resource": {"id": 112, "owner": {"id": 236}, "organization": {"id": 379}, "project": {"owner": {"id": 408}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 39, "privilege": "none"}, "organization": {"id": 188, "owner": {"id": 274}, "user": {"role": null}}}, "resource": {"id": 157, "owner": {"id": 242}, "organization": {"id": 188}, "project": {"owner": {"id": 484}}}} +} + +test_scope_DELETE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "delete", "auth": {"user": {"id": 40, "privilege": "none"}, "organization": {"id": 154, "owner": {"id": 221}, "user": {"role": null}}}, "resource": {"id": 119, "owner": {"id": 272}, "organization": {"id": 372}, "project": {"owner": {"id": 446}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 41, "privilege": "admin"}, "organization": null}, "resource": {"id": 171, "owner": {"id": 292}, "organization": {"id": 305}, "project": {"owner": {"id": 41}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 21, "privilege": "business"}, "organization": null}, "resource": {"id": 188, "owner": {"id": 275}, "organization": {"id": 313}, "project": {"owner": {"id": 21}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 17, "privilege": "user"}, "organization": null}, "resource": {"id": 162, "owner": {"id": 263}, "organization": {"id": 329}, "project": {"owner": {"id": 17}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 55, "privilege": "worker"}, "organization": null}, "resource": {"id": 169, "owner": {"id": 202}, "organization": {"id": 397}, "project": {"owner": {"id": 55}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 33, "privilege": "none"}, "organization": null}, "resource": {"id": 120, "owner": {"id": 264}, "organization": {"id": 307}, "project": {"owner": {"id": 33}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 39, "privilege": "admin"}, "organization": null}, "resource": {"id": 175, "owner": {"id": 39}, "organization": {"id": 388}, "project": {"owner": {"id": 408}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 46, "privilege": "business"}, "organization": null}, "resource": {"id": 143, "owner": {"id": 46}, "organization": {"id": 378}, "project": {"owner": {"id": 438}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 53, "privilege": "user"}, "organization": null}, "resource": {"id": 184, "owner": {"id": 53}, "organization": {"id": 340}, "project": {"owner": {"id": 425}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 38, "privilege": "worker"}, "organization": null}, "resource": {"id": 119, "owner": {"id": 38}, "organization": {"id": 387}, "project": {"owner": {"id": 497}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 82, "privilege": "none"}, "organization": null}, "resource": {"id": 135, "owner": {"id": 82}, "organization": {"id": 370}, "project": {"owner": {"id": 422}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 49, "privilege": "admin"}, "organization": null}, "resource": {"id": 140, "owner": {"id": 242}, "organization": {"id": 374}, "project": {"owner": {"id": 494}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 88, "privilege": "business"}, "organization": null}, "resource": {"id": 104, "owner": {"id": 294}, "organization": {"id": 384}, "project": {"owner": {"id": 406}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 91, "privilege": "user"}, "organization": null}, "resource": {"id": 160, "owner": {"id": 294}, "organization": {"id": 352}, "project": {"owner": {"id": 442}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 83, "privilege": "worker"}, "organization": null}, "resource": {"id": 131, "owner": {"id": 266}, "organization": {"id": 391}, "project": {"owner": {"id": 498}}}} +} + +test_scope_UPDATE_context_SANDBOX_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 79, "privilege": "none"}, "organization": null}, "resource": {"id": 189, "owner": {"id": 221}, "organization": {"id": 306}, "project": {"owner": {"id": 451}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 15, "privilege": "admin"}, "organization": {"id": 101, "owner": {"id": 15}, "user": {"role": "owner"}}}, "resource": {"id": 111, "owner": {"id": 247}, "organization": {"id": 101}, "project": {"owner": {"id": 15}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 86, "privilege": "admin"}, "organization": {"id": 163, "owner": {"id": 86}, "user": {"role": "owner"}}}, "resource": {"id": 167, "owner": {"id": 255}, "organization": {"id": 398}, "project": {"owner": {"id": 86}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 34, "privilege": "admin"}, "organization": {"id": 136, "owner": {"id": 295}, "user": {"role": "maintainer"}}}, "resource": {"id": 128, "owner": {"id": 291}, "organization": {"id": 136}, "project": {"owner": {"id": 34}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 26, "privilege": "admin"}, "organization": {"id": 176, "owner": {"id": 227}, "user": {"role": "maintainer"}}}, "resource": {"id": 163, "owner": {"id": 248}, "organization": {"id": 316}, "project": {"owner": {"id": 26}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 53, "privilege": "admin"}, "organization": {"id": 123, "owner": {"id": 252}, "user": {"role": "supervisor"}}}, "resource": {"id": 136, "owner": {"id": 293}, "organization": {"id": 123}, "project": {"owner": {"id": 53}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 30, "privilege": "admin"}, "organization": {"id": 179, "owner": {"id": 257}, "user": {"role": "supervisor"}}}, "resource": {"id": 118, "owner": {"id": 203}, "organization": {"id": 337}, "project": {"owner": {"id": 30}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 82, "privilege": "admin"}, "organization": {"id": 158, "owner": {"id": 210}, "user": {"role": "worker"}}}, "resource": {"id": 131, "owner": {"id": 247}, "organization": {"id": 158}, "project": {"owner": {"id": 82}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 88, "privilege": "admin"}, "organization": {"id": 150, "owner": {"id": 287}, "user": {"role": "worker"}}}, "resource": {"id": 101, "owner": {"id": 260}, "organization": {"id": 317}, "project": {"owner": {"id": 88}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 54, "privilege": "admin"}, "organization": {"id": 194, "owner": {"id": 276}, "user": {"role": null}}}, "resource": {"id": 127, "owner": {"id": 291}, "organization": {"id": 194}, "project": {"owner": {"id": 54}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 7, "privilege": "admin"}, "organization": {"id": 106, "owner": {"id": 242}, "user": {"role": null}}}, "resource": {"id": 132, "owner": {"id": 209}, "organization": {"id": 312}, "project": {"owner": {"id": 7}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 85, "privilege": "business"}, "organization": {"id": 176, "owner": {"id": 85}, "user": {"role": "owner"}}}, "resource": {"id": 130, "owner": {"id": 260}, "organization": {"id": 176}, "project": {"owner": {"id": 85}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 64, "privilege": "business"}, "organization": {"id": 179, "owner": {"id": 64}, "user": {"role": "owner"}}}, "resource": {"id": 195, "owner": {"id": 205}, "organization": {"id": 300}, "project": {"owner": {"id": 64}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 16, "privilege": "business"}, "organization": {"id": 171, "owner": {"id": 205}, "user": {"role": "maintainer"}}}, "resource": {"id": 138, "owner": {"id": 250}, "organization": {"id": 171}, "project": {"owner": {"id": 16}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 64, "privilege": "business"}, "organization": {"id": 163, "owner": {"id": 243}, "user": {"role": "maintainer"}}}, "resource": {"id": 174, "owner": {"id": 222}, "organization": {"id": 333}, "project": {"owner": {"id": 64}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 90, "privilege": "business"}, "organization": {"id": 167, "owner": {"id": 255}, "user": {"role": "supervisor"}}}, "resource": {"id": 159, "owner": {"id": 216}, "organization": {"id": 167}, "project": {"owner": {"id": 90}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 5, "privilege": "business"}, "organization": {"id": 116, "owner": {"id": 263}, "user": {"role": "supervisor"}}}, "resource": {"id": 118, "owner": {"id": 233}, "organization": {"id": 314}, "project": {"owner": {"id": 5}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 36, "privilege": "business"}, "organization": {"id": 133, "owner": {"id": 278}, "user": {"role": "worker"}}}, "resource": {"id": 135, "owner": {"id": 295}, "organization": {"id": 133}, "project": {"owner": {"id": 36}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 55, "privilege": "business"}, "organization": {"id": 121, "owner": {"id": 240}, "user": {"role": "worker"}}}, "resource": {"id": 197, "owner": {"id": 233}, "organization": {"id": 366}, "project": {"owner": {"id": 55}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 71, "privilege": "business"}, "organization": {"id": 184, "owner": {"id": 281}, "user": {"role": null}}}, "resource": {"id": 194, "owner": {"id": 262}, "organization": {"id": 184}, "project": {"owner": {"id": 71}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 6, "privilege": "business"}, "organization": {"id": 129, "owner": {"id": 223}, "user": {"role": null}}}, "resource": {"id": 156, "owner": {"id": 206}, "organization": {"id": 324}, "project": {"owner": {"id": 6}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 90, "privilege": "user"}, "organization": {"id": 159, "owner": {"id": 90}, "user": {"role": "owner"}}}, "resource": {"id": 104, "owner": {"id": 260}, "organization": {"id": 159}, "project": {"owner": {"id": 90}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 45, "privilege": "user"}, "organization": {"id": 190, "owner": {"id": 45}, "user": {"role": "owner"}}}, "resource": {"id": 114, "owner": {"id": 263}, "organization": {"id": 305}, "project": {"owner": {"id": 45}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 92, "privilege": "user"}, "organization": {"id": 118, "owner": {"id": 215}, "user": {"role": "maintainer"}}}, "resource": {"id": 162, "owner": {"id": 258}, "organization": {"id": 118}, "project": {"owner": {"id": 92}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 23, "privilege": "user"}, "organization": {"id": 121, "owner": {"id": 295}, "user": {"role": "maintainer"}}}, "resource": {"id": 144, "owner": {"id": 211}, "organization": {"id": 326}, "project": {"owner": {"id": 23}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 74, "privilege": "user"}, "organization": {"id": 127, "owner": {"id": 274}, "user": {"role": "supervisor"}}}, "resource": {"id": 165, "owner": {"id": 234}, "organization": {"id": 127}, "project": {"owner": {"id": 74}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 23, "privilege": "user"}, "organization": {"id": 153, "owner": {"id": 209}, "user": {"role": "supervisor"}}}, "resource": {"id": 100, "owner": {"id": 267}, "organization": {"id": 398}, "project": {"owner": {"id": 23}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 12, "privilege": "user"}, "organization": {"id": 142, "owner": {"id": 203}, "user": {"role": "worker"}}}, "resource": {"id": 131, "owner": {"id": 298}, "organization": {"id": 142}, "project": {"owner": {"id": 12}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 50, "privilege": "user"}, "organization": {"id": 147, "owner": {"id": 275}, "user": {"role": "worker"}}}, "resource": {"id": 136, "owner": {"id": 287}, "organization": {"id": 387}, "project": {"owner": {"id": 50}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 10, "privilege": "user"}, "organization": {"id": 158, "owner": {"id": 290}, "user": {"role": null}}}, "resource": {"id": 113, "owner": {"id": 241}, "organization": {"id": 158}, "project": {"owner": {"id": 10}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 34, "privilege": "user"}, "organization": {"id": 176, "owner": {"id": 284}, "user": {"role": null}}}, "resource": {"id": 184, "owner": {"id": 261}, "organization": {"id": 358}, "project": {"owner": {"id": 34}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 79, "privilege": "worker"}, "organization": {"id": 157, "owner": {"id": 79}, "user": {"role": "owner"}}}, "resource": {"id": 100, "owner": {"id": 242}, "organization": {"id": 157}, "project": {"owner": {"id": 79}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 27, "privilege": "worker"}, "organization": {"id": 106, "owner": {"id": 27}, "user": {"role": "owner"}}}, "resource": {"id": 193, "owner": {"id": 293}, "organization": {"id": 372}, "project": {"owner": {"id": 27}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 9, "privilege": "worker"}, "organization": {"id": 170, "owner": {"id": 277}, "user": {"role": "maintainer"}}}, "resource": {"id": 136, "owner": {"id": 241}, "organization": {"id": 170}, "project": {"owner": {"id": 9}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 22, "privilege": "worker"}, "organization": {"id": 130, "owner": {"id": 298}, "user": {"role": "maintainer"}}}, "resource": {"id": 148, "owner": {"id": 211}, "organization": {"id": 351}, "project": {"owner": {"id": 22}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 20, "privilege": "worker"}, "organization": {"id": 149, "owner": {"id": 299}, "user": {"role": "supervisor"}}}, "resource": {"id": 151, "owner": {"id": 211}, "organization": {"id": 149}, "project": {"owner": {"id": 20}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 83, "privilege": "worker"}, "organization": {"id": 119, "owner": {"id": 215}, "user": {"role": "supervisor"}}}, "resource": {"id": 151, "owner": {"id": 236}, "organization": {"id": 382}, "project": {"owner": {"id": 83}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 39, "privilege": "worker"}, "organization": {"id": 187, "owner": {"id": 277}, "user": {"role": "worker"}}}, "resource": {"id": 138, "owner": {"id": 262}, "organization": {"id": 187}, "project": {"owner": {"id": 39}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 47, "privilege": "worker"}, "organization": {"id": 165, "owner": {"id": 225}, "user": {"role": "worker"}}}, "resource": {"id": 116, "owner": {"id": 245}, "organization": {"id": 385}, "project": {"owner": {"id": 47}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 34, "privilege": "worker"}, "organization": {"id": 172, "owner": {"id": 276}, "user": {"role": null}}}, "resource": {"id": 162, "owner": {"id": 257}, "organization": {"id": 172}, "project": {"owner": {"id": 34}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 47, "privilege": "worker"}, "organization": {"id": 178, "owner": {"id": 223}, "user": {"role": null}}}, "resource": {"id": 154, "owner": {"id": 211}, "organization": {"id": 396}, "project": {"owner": {"id": 47}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 5, "privilege": "none"}, "organization": {"id": 165, "owner": {"id": 5}, "user": {"role": "owner"}}}, "resource": {"id": 155, "owner": {"id": 280}, "organization": {"id": 165}, "project": {"owner": {"id": 5}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 87, "privilege": "none"}, "organization": {"id": 191, "owner": {"id": 87}, "user": {"role": "owner"}}}, "resource": {"id": 159, "owner": {"id": 286}, "organization": {"id": 328}, "project": {"owner": {"id": 87}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 67, "privilege": "none"}, "organization": {"id": 110, "owner": {"id": 237}, "user": {"role": "maintainer"}}}, "resource": {"id": 163, "owner": {"id": 267}, "organization": {"id": 110}, "project": {"owner": {"id": 67}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 38, "privilege": "none"}, "organization": {"id": 119, "owner": {"id": 216}, "user": {"role": "maintainer"}}}, "resource": {"id": 159, "owner": {"id": 255}, "organization": {"id": 310}, "project": {"owner": {"id": 38}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 54, "privilege": "none"}, "organization": {"id": 137, "owner": {"id": 272}, "user": {"role": "supervisor"}}}, "resource": {"id": 187, "owner": {"id": 238}, "organization": {"id": 137}, "project": {"owner": {"id": 54}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 65, "privilege": "none"}, "organization": {"id": 113, "owner": {"id": 265}, "user": {"role": "supervisor"}}}, "resource": {"id": 106, "owner": {"id": 270}, "organization": {"id": 309}, "project": {"owner": {"id": 65}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 84, "privilege": "none"}, "organization": {"id": 192, "owner": {"id": 272}, "user": {"role": "worker"}}}, "resource": {"id": 154, "owner": {"id": 252}, "organization": {"id": 192}, "project": {"owner": {"id": 84}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 10, "privilege": "none"}, "organization": {"id": 198, "owner": {"id": 264}, "user": {"role": "worker"}}}, "resource": {"id": 132, "owner": {"id": 245}, "organization": {"id": 332}, "project": {"owner": {"id": 10}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 59, "privilege": "none"}, "organization": {"id": 122, "owner": {"id": 223}, "user": {"role": null}}}, "resource": {"id": 102, "owner": {"id": 214}, "organization": {"id": 122}, "project": {"owner": {"id": 59}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_PROJECT_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 90, "privilege": "none"}, "organization": {"id": 138, "owner": {"id": 240}, "user": {"role": null}}}, "resource": {"id": 150, "owner": {"id": 205}, "organization": {"id": 310}, "project": {"owner": {"id": 90}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 85, "privilege": "admin"}, "organization": {"id": 157, "owner": {"id": 85}, "user": {"role": "owner"}}}, "resource": {"id": 175, "owner": {"id": 85}, "organization": {"id": 157}, "project": {"owner": {"id": 496}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 23, "privilege": "admin"}, "organization": {"id": 110, "owner": {"id": 23}, "user": {"role": "owner"}}}, "resource": {"id": 133, "owner": {"id": 23}, "organization": {"id": 323}, "project": {"owner": {"id": 427}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 55, "privilege": "admin"}, "organization": {"id": 168, "owner": {"id": 202}, "user": {"role": "maintainer"}}}, "resource": {"id": 177, "owner": {"id": 55}, "organization": {"id": 168}, "project": {"owner": {"id": 413}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 21, "privilege": "admin"}, "organization": {"id": 183, "owner": {"id": 261}, "user": {"role": "maintainer"}}}, "resource": {"id": 100, "owner": {"id": 21}, "organization": {"id": 314}, "project": {"owner": {"id": 401}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 95, "privilege": "admin"}, "organization": {"id": 177, "owner": {"id": 292}, "user": {"role": "supervisor"}}}, "resource": {"id": 158, "owner": {"id": 95}, "organization": {"id": 177}, "project": {"owner": {"id": 484}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 46, "privilege": "admin"}, "organization": {"id": 188, "owner": {"id": 286}, "user": {"role": "supervisor"}}}, "resource": {"id": 162, "owner": {"id": 46}, "organization": {"id": 379}, "project": {"owner": {"id": 478}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 11, "privilege": "admin"}, "organization": {"id": 116, "owner": {"id": 217}, "user": {"role": "worker"}}}, "resource": {"id": 188, "owner": {"id": 11}, "organization": {"id": 116}, "project": {"owner": {"id": 438}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 89, "privilege": "admin"}, "organization": {"id": 124, "owner": {"id": 203}, "user": {"role": "worker"}}}, "resource": {"id": 101, "owner": {"id": 89}, "organization": {"id": 314}, "project": {"owner": {"id": 402}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 83, "privilege": "admin"}, "organization": {"id": 130, "owner": {"id": 255}, "user": {"role": null}}}, "resource": {"id": 126, "owner": {"id": 83}, "organization": {"id": 130}, "project": {"owner": {"id": 418}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 38, "privilege": "admin"}, "organization": {"id": 198, "owner": {"id": 259}, "user": {"role": null}}}, "resource": {"id": 180, "owner": {"id": 38}, "organization": {"id": 324}, "project": {"owner": {"id": 411}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 14, "privilege": "business"}, "organization": {"id": 121, "owner": {"id": 14}, "user": {"role": "owner"}}}, "resource": {"id": 118, "owner": {"id": 14}, "organization": {"id": 121}, "project": {"owner": {"id": 402}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 60, "privilege": "business"}, "organization": {"id": 102, "owner": {"id": 60}, "user": {"role": "owner"}}}, "resource": {"id": 194, "owner": {"id": 60}, "organization": {"id": 356}, "project": {"owner": {"id": 462}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 72, "privilege": "business"}, "organization": {"id": 150, "owner": {"id": 206}, "user": {"role": "maintainer"}}}, "resource": {"id": 174, "owner": {"id": 72}, "organization": {"id": 150}, "project": {"owner": {"id": 437}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 91, "privilege": "business"}, "organization": {"id": 155, "owner": {"id": 253}, "user": {"role": "maintainer"}}}, "resource": {"id": 142, "owner": {"id": 91}, "organization": {"id": 313}, "project": {"owner": {"id": 480}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 93, "privilege": "business"}, "organization": {"id": 170, "owner": {"id": 237}, "user": {"role": "supervisor"}}}, "resource": {"id": 163, "owner": {"id": 93}, "organization": {"id": 170}, "project": {"owner": {"id": 476}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 65, "privilege": "business"}, "organization": {"id": 175, "owner": {"id": 292}, "user": {"role": "supervisor"}}}, "resource": {"id": 104, "owner": {"id": 65}, "organization": {"id": 351}, "project": {"owner": {"id": 462}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 91, "privilege": "business"}, "organization": {"id": 107, "owner": {"id": 267}, "user": {"role": "worker"}}}, "resource": {"id": 178, "owner": {"id": 91}, "organization": {"id": 107}, "project": {"owner": {"id": 429}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 70, "privilege": "business"}, "organization": {"id": 108, "owner": {"id": 234}, "user": {"role": "worker"}}}, "resource": {"id": 102, "owner": {"id": 70}, "organization": {"id": 373}, "project": {"owner": {"id": 440}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 63, "privilege": "business"}, "organization": {"id": 179, "owner": {"id": 282}, "user": {"role": null}}}, "resource": {"id": 182, "owner": {"id": 63}, "organization": {"id": 179}, "project": {"owner": {"id": 414}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 2, "privilege": "business"}, "organization": {"id": 120, "owner": {"id": 236}, "user": {"role": null}}}, "resource": {"id": 144, "owner": {"id": 2}, "organization": {"id": 389}, "project": {"owner": {"id": 455}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 56, "privilege": "user"}, "organization": {"id": 129, "owner": {"id": 56}, "user": {"role": "owner"}}}, "resource": {"id": 192, "owner": {"id": 56}, "organization": {"id": 129}, "project": {"owner": {"id": 444}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 77, "privilege": "user"}, "organization": {"id": 191, "owner": {"id": 77}, "user": {"role": "owner"}}}, "resource": {"id": 128, "owner": {"id": 77}, "organization": {"id": 387}, "project": {"owner": {"id": 443}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 91, "privilege": "user"}, "organization": {"id": 149, "owner": {"id": 253}, "user": {"role": "maintainer"}}}, "resource": {"id": 159, "owner": {"id": 91}, "organization": {"id": 149}, "project": {"owner": {"id": 492}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 60, "privilege": "user"}, "organization": {"id": 120, "owner": {"id": 279}, "user": {"role": "maintainer"}}}, "resource": {"id": 186, "owner": {"id": 60}, "organization": {"id": 394}, "project": {"owner": {"id": 446}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 11, "privilege": "user"}, "organization": {"id": 185, "owner": {"id": 261}, "user": {"role": "supervisor"}}}, "resource": {"id": 126, "owner": {"id": 11}, "organization": {"id": 185}, "project": {"owner": {"id": 495}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 77, "privilege": "user"}, "organization": {"id": 162, "owner": {"id": 212}, "user": {"role": "supervisor"}}}, "resource": {"id": 116, "owner": {"id": 77}, "organization": {"id": 325}, "project": {"owner": {"id": 420}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 26, "privilege": "user"}, "organization": {"id": 171, "owner": {"id": 206}, "user": {"role": "worker"}}}, "resource": {"id": 153, "owner": {"id": 26}, "organization": {"id": 171}, "project": {"owner": {"id": 430}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 86, "privilege": "user"}, "organization": {"id": 120, "owner": {"id": 222}, "user": {"role": "worker"}}}, "resource": {"id": 102, "owner": {"id": 86}, "organization": {"id": 333}, "project": {"owner": {"id": 445}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 55, "privilege": "user"}, "organization": {"id": 114, "owner": {"id": 279}, "user": {"role": null}}}, "resource": {"id": 164, "owner": {"id": 55}, "organization": {"id": 114}, "project": {"owner": {"id": 443}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 62, "privilege": "user"}, "organization": {"id": 133, "owner": {"id": 243}, "user": {"role": null}}}, "resource": {"id": 129, "owner": {"id": 62}, "organization": {"id": 371}, "project": {"owner": {"id": 428}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 71, "privilege": "worker"}, "organization": {"id": 134, "owner": {"id": 71}, "user": {"role": "owner"}}}, "resource": {"id": 145, "owner": {"id": 71}, "organization": {"id": 134}, "project": {"owner": {"id": 498}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 13, "privilege": "worker"}, "organization": {"id": 190, "owner": {"id": 13}, "user": {"role": "owner"}}}, "resource": {"id": 188, "owner": {"id": 13}, "organization": {"id": 377}, "project": {"owner": {"id": 485}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 13, "privilege": "worker"}, "organization": {"id": 178, "owner": {"id": 278}, "user": {"role": "maintainer"}}}, "resource": {"id": 156, "owner": {"id": 13}, "organization": {"id": 178}, "project": {"owner": {"id": 414}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 13, "privilege": "worker"}, "organization": {"id": 121, "owner": {"id": 280}, "user": {"role": "maintainer"}}}, "resource": {"id": 113, "owner": {"id": 13}, "organization": {"id": 305}, "project": {"owner": {"id": 464}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 93, "privilege": "worker"}, "organization": {"id": 137, "owner": {"id": 290}, "user": {"role": "supervisor"}}}, "resource": {"id": 115, "owner": {"id": 93}, "organization": {"id": 137}, "project": {"owner": {"id": 420}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 93, "privilege": "worker"}, "organization": {"id": 112, "owner": {"id": 219}, "user": {"role": "supervisor"}}}, "resource": {"id": 156, "owner": {"id": 93}, "organization": {"id": 364}, "project": {"owner": {"id": 463}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 15, "privilege": "worker"}, "organization": {"id": 125, "owner": {"id": 256}, "user": {"role": "worker"}}}, "resource": {"id": 182, "owner": {"id": 15}, "organization": {"id": 125}, "project": {"owner": {"id": 417}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 31, "privilege": "worker"}, "organization": {"id": 160, "owner": {"id": 243}, "user": {"role": "worker"}}}, "resource": {"id": 153, "owner": {"id": 31}, "organization": {"id": 398}, "project": {"owner": {"id": 400}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 34, "privilege": "worker"}, "organization": {"id": 147, "owner": {"id": 291}, "user": {"role": null}}}, "resource": {"id": 136, "owner": {"id": 34}, "organization": {"id": 147}, "project": {"owner": {"id": 410}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 85, "privilege": "worker"}, "organization": {"id": 131, "owner": {"id": 219}, "user": {"role": null}}}, "resource": {"id": 196, "owner": {"id": 85}, "organization": {"id": 346}, "project": {"owner": {"id": 480}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 29, "privilege": "none"}, "organization": {"id": 117, "owner": {"id": 29}, "user": {"role": "owner"}}}, "resource": {"id": 196, "owner": {"id": 29}, "organization": {"id": 117}, "project": {"owner": {"id": 453}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 87, "privilege": "none"}, "organization": {"id": 146, "owner": {"id": 87}, "user": {"role": "owner"}}}, "resource": {"id": 152, "owner": {"id": 87}, "organization": {"id": 358}, "project": {"owner": {"id": 466}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 91, "privilege": "none"}, "organization": {"id": 166, "owner": {"id": 208}, "user": {"role": "maintainer"}}}, "resource": {"id": 156, "owner": {"id": 91}, "organization": {"id": 166}, "project": {"owner": {"id": 401}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 98, "privilege": "none"}, "organization": {"id": 121, "owner": {"id": 255}, "user": {"role": "maintainer"}}}, "resource": {"id": 110, "owner": {"id": 98}, "organization": {"id": 340}, "project": {"owner": {"id": 451}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 84, "privilege": "none"}, "organization": {"id": 182, "owner": {"id": 219}, "user": {"role": "supervisor"}}}, "resource": {"id": 107, "owner": {"id": 84}, "organization": {"id": 182}, "project": {"owner": {"id": 404}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 84, "privilege": "none"}, "organization": {"id": 166, "owner": {"id": 237}, "user": {"role": "supervisor"}}}, "resource": {"id": 173, "owner": {"id": 84}, "organization": {"id": 380}, "project": {"owner": {"id": 491}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 70, "privilege": "none"}, "organization": {"id": 138, "owner": {"id": 292}, "user": {"role": "worker"}}}, "resource": {"id": 153, "owner": {"id": 70}, "organization": {"id": 138}, "project": {"owner": {"id": 489}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 35, "privilege": "none"}, "organization": {"id": 167, "owner": {"id": 278}, "user": {"role": "worker"}}}, "resource": {"id": 195, "owner": {"id": 35}, "organization": {"id": 344}, "project": {"owner": {"id": 401}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 82, "privilege": "none"}, "organization": {"id": 126, "owner": {"id": 269}, "user": {"role": null}}}, "resource": {"id": 118, "owner": {"id": 82}, "organization": {"id": 126}, "project": {"owner": {"id": 457}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_OWNER_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 89, "privilege": "none"}, "organization": {"id": 144, "owner": {"id": 283}, "user": {"role": null}}}, "resource": {"id": 179, "owner": {"id": 89}, "organization": {"id": 383}, "project": {"owner": {"id": 456}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 10, "privilege": "admin"}, "organization": {"id": 104, "owner": {"id": 10}, "user": {"role": "owner"}}}, "resource": {"id": 151, "owner": {"id": 299}, "organization": {"id": 104}, "project": {"owner": {"id": 438}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_OWNER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 94, "privilege": "admin"}, "organization": {"id": 116, "owner": {"id": 94}, "user": {"role": "owner"}}}, "resource": {"id": 142, "owner": {"id": 249}, "organization": {"id": 392}, "project": {"owner": {"id": 492}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 65, "privilege": "admin"}, "organization": {"id": 170, "owner": {"id": 239}, "user": {"role": "maintainer"}}}, "resource": {"id": 169, "owner": {"id": 236}, "organization": {"id": 170}, "project": {"owner": {"id": 411}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_MAINTAINER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 20, "privilege": "admin"}, "organization": {"id": 102, "owner": {"id": 203}, "user": {"role": "maintainer"}}}, "resource": {"id": 166, "owner": {"id": 243}, "organization": {"id": 396}, "project": {"owner": {"id": 481}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 99, "privilege": "admin"}, "organization": {"id": 138, "owner": {"id": 292}, "user": {"role": "supervisor"}}}, "resource": {"id": 169, "owner": {"id": 275}, "organization": {"id": 138}, "project": {"owner": {"id": 430}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_SUPERVISOR_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 7, "privilege": "admin"}, "organization": {"id": 147, "owner": {"id": 233}, "user": {"role": "supervisor"}}}, "resource": {"id": 154, "owner": {"id": 264}, "organization": {"id": 314}, "project": {"owner": {"id": 493}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 6, "privilege": "admin"}, "organization": {"id": 196, "owner": {"id": 283}, "user": {"role": "worker"}}}, "resource": {"id": 180, "owner": {"id": 264}, "organization": {"id": 196}, "project": {"owner": {"id": 415}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_WORKER_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 94, "privilege": "admin"}, "organization": {"id": 100, "owner": {"id": 258}, "user": {"role": "worker"}}}, "resource": {"id": 115, "owner": {"id": 257}, "organization": {"id": 338}, "project": {"owner": {"id": 473}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 72, "privilege": "admin"}, "organization": {"id": 191, "owner": {"id": 280}, "user": {"role": null}}}, "resource": {"id": 143, "owner": {"id": 230}, "organization": {"id": 191}, "project": {"owner": {"id": 474}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_ADMIN_membership_NONE_resource_project_same_org_FALSE { + allow with input as {"scope": "update", "auth": {"user": {"id": 44, "privilege": "admin"}, "organization": {"id": 138, "owner": {"id": 236}, "user": {"role": null}}}, "resource": {"id": 185, "owner": {"id": 268}, "organization": {"id": 376}, "project": {"owner": {"id": 429}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 17, "privilege": "business"}, "organization": {"id": 191, "owner": {"id": 17}, "user": {"role": "owner"}}}, "resource": {"id": 185, "owner": {"id": 291}, "organization": {"id": 191}, "project": {"owner": {"id": 486}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 41, "privilege": "business"}, "organization": {"id": 166, "owner": {"id": 41}, "user": {"role": "owner"}}}, "resource": {"id": 166, "owner": {"id": 294}, "organization": {"id": 331}, "project": {"owner": {"id": 413}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 12, "privilege": "business"}, "organization": {"id": 118, "owner": {"id": 291}, "user": {"role": "maintainer"}}}, "resource": {"id": 124, "owner": {"id": 299}, "organization": {"id": 118}, "project": {"owner": {"id": 458}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 7, "privilege": "business"}, "organization": {"id": 154, "owner": {"id": 216}, "user": {"role": "maintainer"}}}, "resource": {"id": 154, "owner": {"id": 252}, "organization": {"id": 310}, "project": {"owner": {"id": 411}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 47, "privilege": "business"}, "organization": {"id": 186, "owner": {"id": 267}, "user": {"role": "supervisor"}}}, "resource": {"id": 160, "owner": {"id": 227}, "organization": {"id": 186}, "project": {"owner": {"id": 409}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 99, "privilege": "business"}, "organization": {"id": 150, "owner": {"id": 233}, "user": {"role": "supervisor"}}}, "resource": {"id": 151, "owner": {"id": 275}, "organization": {"id": 371}, "project": {"owner": {"id": 466}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 56, "privilege": "business"}, "organization": {"id": 188, "owner": {"id": 262}, "user": {"role": "worker"}}}, "resource": {"id": 174, "owner": {"id": 263}, "organization": {"id": 188}, "project": {"owner": {"id": 454}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 64, "privilege": "business"}, "organization": {"id": 116, "owner": {"id": 262}, "user": {"role": "worker"}}}, "resource": {"id": 193, "owner": {"id": 253}, "organization": {"id": 320}, "project": {"owner": {"id": 418}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 65, "privilege": "business"}, "organization": {"id": 167, "owner": {"id": 215}, "user": {"role": null}}}, "resource": {"id": 191, "owner": {"id": 258}, "organization": {"id": 167}, "project": {"owner": {"id": 490}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_BUSINESS_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 4, "privilege": "business"}, "organization": {"id": 195, "owner": {"id": 220}, "user": {"role": null}}}, "resource": {"id": 188, "owner": {"id": 227}, "organization": {"id": 358}, "project": {"owner": {"id": 460}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 52, "privilege": "user"}, "organization": {"id": 114, "owner": {"id": 52}, "user": {"role": "owner"}}}, "resource": {"id": 171, "owner": {"id": 247}, "organization": {"id": 114}, "project": {"owner": {"id": 477}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 66, "privilege": "user"}, "organization": {"id": 119, "owner": {"id": 66}, "user": {"role": "owner"}}}, "resource": {"id": 147, "owner": {"id": 273}, "organization": {"id": 337}, "project": {"owner": {"id": 405}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 37, "privilege": "user"}, "organization": {"id": 162, "owner": {"id": 203}, "user": {"role": "maintainer"}}}, "resource": {"id": 147, "owner": {"id": 239}, "organization": {"id": 162}, "project": {"owner": {"id": 438}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 6, "privilege": "user"}, "organization": {"id": 139, "owner": {"id": 236}, "user": {"role": "maintainer"}}}, "resource": {"id": 118, "owner": {"id": 218}, "organization": {"id": 375}, "project": {"owner": {"id": 453}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 0, "privilege": "user"}, "organization": {"id": 115, "owner": {"id": 270}, "user": {"role": "supervisor"}}}, "resource": {"id": 127, "owner": {"id": 223}, "organization": {"id": 115}, "project": {"owner": {"id": 462}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 61, "privilege": "user"}, "organization": {"id": 173, "owner": {"id": 239}, "user": {"role": "supervisor"}}}, "resource": {"id": 144, "owner": {"id": 208}, "organization": {"id": 305}, "project": {"owner": {"id": 453}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 60, "privilege": "user"}, "organization": {"id": 192, "owner": {"id": 277}, "user": {"role": "worker"}}}, "resource": {"id": 172, "owner": {"id": 257}, "organization": {"id": 192}, "project": {"owner": {"id": 448}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 81, "privilege": "user"}, "organization": {"id": 158, "owner": {"id": 297}, "user": {"role": "worker"}}}, "resource": {"id": 116, "owner": {"id": 297}, "organization": {"id": 317}, "project": {"owner": {"id": 464}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 93, "privilege": "user"}, "organization": {"id": 141, "owner": {"id": 211}, "user": {"role": null}}}, "resource": {"id": 157, "owner": {"id": 285}, "organization": {"id": 141}, "project": {"owner": {"id": 419}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_USER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 49, "privilege": "user"}, "organization": {"id": 129, "owner": {"id": 250}, "user": {"role": null}}}, "resource": {"id": 179, "owner": {"id": 285}, "organization": {"id": 379}, "project": {"owner": {"id": 452}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 56, "privilege": "worker"}, "organization": {"id": 194, "owner": {"id": 56}, "user": {"role": "owner"}}}, "resource": {"id": 143, "owner": {"id": 269}, "organization": {"id": 194}, "project": {"owner": {"id": 485}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 3, "privilege": "worker"}, "organization": {"id": 173, "owner": {"id": 3}, "user": {"role": "owner"}}}, "resource": {"id": 182, "owner": {"id": 211}, "organization": {"id": 321}, "project": {"owner": {"id": 490}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_TRUE { + allow with input as {"scope": "update", "auth": {"user": {"id": 82, "privilege": "worker"}, "organization": {"id": 115, "owner": {"id": 282}, "user": {"role": "maintainer"}}}, "resource": {"id": 198, "owner": {"id": 290}, "organization": {"id": 115}, "project": {"owner": {"id": 471}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 69, "privilege": "worker"}, "organization": {"id": 173, "owner": {"id": 296}, "user": {"role": "maintainer"}}}, "resource": {"id": 171, "owner": {"id": 210}, "organization": {"id": 324}, "project": {"owner": {"id": 479}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 96, "privilege": "worker"}, "organization": {"id": 160, "owner": {"id": 264}, "user": {"role": "supervisor"}}}, "resource": {"id": 106, "owner": {"id": 272}, "organization": {"id": 160}, "project": {"owner": {"id": 424}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 23, "privilege": "worker"}, "organization": {"id": 102, "owner": {"id": 235}, "user": {"role": "supervisor"}}}, "resource": {"id": 102, "owner": {"id": 215}, "organization": {"id": 384}, "project": {"owner": {"id": 468}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 56, "privilege": "worker"}, "organization": {"id": 110, "owner": {"id": 268}, "user": {"role": "worker"}}}, "resource": {"id": 126, "owner": {"id": 231}, "organization": {"id": 110}, "project": {"owner": {"id": 474}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 5, "privilege": "worker"}, "organization": {"id": 162, "owner": {"id": 202}, "user": {"role": "worker"}}}, "resource": {"id": 177, "owner": {"id": 241}, "organization": {"id": 351}, "project": {"owner": {"id": 475}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 3, "privilege": "worker"}, "organization": {"id": 167, "owner": {"id": 240}, "user": {"role": null}}}, "resource": {"id": 143, "owner": {"id": 255}, "organization": {"id": 167}, "project": {"owner": {"id": 453}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_WORKER_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 12, "privilege": "worker"}, "organization": {"id": 169, "owner": {"id": 221}, "user": {"role": null}}}, "resource": {"id": 148, "owner": {"id": 232}, "organization": {"id": 311}, "project": {"owner": {"id": 414}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 98, "privilege": "none"}, "organization": {"id": 111, "owner": {"id": 98}, "user": {"role": "owner"}}}, "resource": {"id": 136, "owner": {"id": 218}, "organization": {"id": 111}, "project": {"owner": {"id": 432}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_OWNER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 7, "privilege": "none"}, "organization": {"id": 178, "owner": {"id": 7}, "user": {"role": "owner"}}}, "resource": {"id": 139, "owner": {"id": 252}, "organization": {"id": 386}, "project": {"owner": {"id": 421}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 38, "privilege": "none"}, "organization": {"id": 149, "owner": {"id": 212}, "user": {"role": "maintainer"}}}, "resource": {"id": 123, "owner": {"id": 203}, "organization": {"id": 149}, "project": {"owner": {"id": 407}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_MAINTAINER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 33, "privilege": "none"}, "organization": {"id": 148, "owner": {"id": 215}, "user": {"role": "maintainer"}}}, "resource": {"id": 140, "owner": {"id": 218}, "organization": {"id": 352}, "project": {"owner": {"id": 441}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 76, "privilege": "none"}, "organization": {"id": 175, "owner": {"id": 230}, "user": {"role": "supervisor"}}}, "resource": {"id": 106, "owner": {"id": 225}, "organization": {"id": 175}, "project": {"owner": {"id": 496}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_SUPERVISOR_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 49, "privilege": "none"}, "organization": {"id": 114, "owner": {"id": 234}, "user": {"role": "supervisor"}}}, "resource": {"id": 163, "owner": {"id": 265}, "organization": {"id": 396}, "project": {"owner": {"id": 464}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 52, "privilege": "none"}, "organization": {"id": 108, "owner": {"id": 261}, "user": {"role": "worker"}}}, "resource": {"id": 107, "owner": {"id": 240}, "organization": {"id": 108}, "project": {"owner": {"id": 470}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_WORKER_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 2, "privilege": "none"}, "organization": {"id": 134, "owner": {"id": 270}, "user": {"role": "worker"}}}, "resource": {"id": 103, "owner": {"id": 252}, "organization": {"id": 381}, "project": {"owner": {"id": 429}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_TRUE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 97, "privilege": "none"}, "organization": {"id": 171, "owner": {"id": 283}, "user": {"role": null}}}, "resource": {"id": 175, "owner": {"id": 286}, "organization": {"id": 171}, "project": {"owner": {"id": 431}}}} +} + +test_scope_UPDATE_context_ORGANIZATION_ownership_NONE_privilege_NONE_membership_NONE_resource_project_same_org_FALSE { + not allow with input as {"scope": "update", "auth": {"user": {"id": 57, "privilege": "none"}, "organization": {"id": 110, "owner": {"id": 281}, "user": {"role": null}}}, "resource": {"id": 116, "owner": {"id": 238}, "organization": {"id": 372}, "project": {"owner": {"id": 452}}}} +} + + +# import csv +# import json +# import random +# from itertools import product +# +# NAME = "webhooks" +# +# +# def read_rules(name): +# rules = [] +# with open(f"{name}.csv") as f: +# reader = csv.DictReader(f) +# for row in reader: +# row = {k.lower(): v.lower().replace("n/a", "na") for k, v in row.items()} +# row["limit"] = row["limit"].replace("none", "None") +# found = False +# for col, val in row.items(): +# if col in ["limit", "method", "url", "resource"]: +# continue +# complex_val = [v.strip() for v in val.split(",")] +# if len(complex_val) > 1: +# found = True +# for item in complex_val: +# new_row = row.copy() +# new_row[col] = item +# rules.append(new_row) +# if not found: +# rules.append(row) +# return rules +# +# +# random.seed(42) +# simple_rules = read_rules(NAME) +# SCOPES = list({rule["scope"] for rule in simple_rules}) +# CONTEXTS = ["sandbox", "organization"] +# OWNERSHIPS = ["project:owner", "owner", "none"] +# GROUPS = ["admin", "business", "user", "worker", "none"] +# ORG_ROLES = ["owner", "maintainer", "supervisor", "worker", None] +# SAME_ORG = [True, False] +# +# +# def RESOURCES(scope): +# if scope == "list": +# return [None] +# elif scope == "create@project": +# return [ +# { +# "owner": {"id": random.randrange(100, 200)}, +# "assignee": {"id": random.randrange(200, 300)}, +# "organization": {"id": random.randrange(300, 400)}, +# "project": {"owner": {"id": random.randrange(400, 500)}}, +# "num_resources": count, +# } +# for count in (0, 3, 10) +# ] +# elif scope == "create@organization": +# return [ +# { +# "owner": {"id": random.randrange(100, 200)}, +# "assignee": {"id": random.randrange(200, 300)}, +# "organization": {"id": random.randrange(300, 400)}, +# "project": None, +# "num_resources": count, +# } +# for count in (0, 3, 10) +# ] +# else: +# return [ +# { +# "id": random.randrange(100, 200), +# "owner": {"id": random.randrange(200, 300)}, +# "organization": {"id": random.randrange(300, 400)}, +# "project": {"owner": {"id": random.randrange(400, 500)}}, +# } +# ] +# +# +# def is_same_org(org1, org2): +# if org1 is not None and org2 is not None: +# return org1["id"] == org2["id"] +# elif org1 is None and org2 is None: +# return True +# return False +# +# +# def eval_rule(scope, context, ownership, privilege, membership, data): +# if privilege == "admin": +# return True +# +# rules = list( +# filter( +# lambda r: scope == r["scope"] +# and (r["context"] == "na" or context == r["context"]) +# and (r["ownership"] == "na" or ownership == r["ownership"]) +# and ( +# r["membership"] == "na" +# or ORG_ROLES.index(membership) <= ORG_ROLES.index(r["membership"]) +# ) +# and GROUPS.index(privilege) <= GROUPS.index(r["privilege"]), +# simple_rules, +# ) +# ) +# +# resource = data["resource"] +# +# rules = list( +# filter( +# lambda r: not r["limit"] or eval(r["limit"], {"resource": resource}), rules +# ) +# ) +# if ( +# not is_same_org(data["auth"]["organization"], data["resource"]["organization"]) +# and context != "sandbox" +# ): +# return False +# return bool(rules) +# +# +# def get_data(scope, context, ownership, privilege, membership, resource, same_org): +# data = { +# "scope": scope, +# "auth": { +# "user": {"id": random.randrange(0, 100), "privilege": privilege}, +# "organization": { +# "id": random.randrange(100, 200), +# "owner": {"id": random.randrange(200, 300)}, +# "user": {"role": membership}, +# } +# if context == "organization" +# else None, +# }, +# "resource": resource, +# } +# +# user_id = data["auth"]["user"]["id"] +# +# if context == "organization": +# org_id = data["auth"]["organization"]["id"] +# if data["auth"]["organization"]["user"]["role"] == "owner": +# data["auth"]["organization"]["owner"]["id"] = user_id +# if same_org: +# data["resource"]["organization"]["id"] = org_id +# +# if ownership == "owner": +# data["resource"]["owner"]["id"] = user_id +# +# if ownership == "project:owner": +# data["resource"]["project"]["owner"]["id"] = user_id +# +# return data +# +# +# def _get_name(prefix, **kwargs): +# name = prefix +# for k, v in kwargs.items(): +# prefix = "_" + str(k) +# if isinstance(v, dict): +# if "id" in v: +# v = v.copy() +# v.pop("id") +# if v: +# name += _get_name(prefix, **v) +# else: +# name += "".join( +# map( +# lambda c: c if c.isalnum() else {"@": "_IN_"}.get(c, "_"), +# f"{prefix}_{str(v).upper()}", +# ) +# ) +# return name +# +# +# def get_name(scope, context, ownership, privilege, membership, resource, same_org): +# return _get_name("test", **locals()) +# +# +# def is_valid(scope, context, ownership, privilege, membership, resource, same_org): +# if context == "sandbox" and membership: +# return False +# if scope == "list" and ownership != "None": +# return False +# if context == "sandbox" and not same_org: +# return False +# if scope.startswith("create") and ownership != "None": +# return False +# +# return True +# +# +# def gen_test_rego(name): +# with open(f"{name}_test.gen.rego", "wt") as f: +# f.write(f"package {name}\n\n") +# for scope, context, ownership, privilege, membership, same_org in product( +# SCOPES, CONTEXTS, OWNERSHIPS, GROUPS, ORG_ROLES, SAME_ORG +# ): +# for resource in RESOURCES(scope): +# +# if not is_valid( +# scope, context, ownership, privilege, membership, resource, same_org +# ): +# continue +# +# data = get_data( +# scope, context, ownership, privilege, membership, resource, same_org +# ) +# test_name = get_name( +# scope, context, ownership, privilege, membership, resource, same_org +# ) +# result = eval_rule( +# scope, context, ownership, privilege, membership, data +# ) +# +# f.write( +# "{test_name} {{\n {allow} with input as {data}\n}}\n\n".format( +# test_name=test_name, +# allow="allow" if result else "not allow", +# data=json.dumps(data), +# ) +# ) +# +# +# gen_test_rego(NAME) +# \ No newline at end of file diff --git a/cvat/apps/iam/schema.py b/cvat/apps/iam/schema.py index e9a510044a5e..0fb74b63f40e 100644 --- a/cvat/apps/iam/schema.py +++ b/cvat/apps/iam/schema.py @@ -4,14 +4,18 @@ # SPDX-License-Identifier: MIT import re +import textwrap from drf_spectacular.openapi import AutoSchema from drf_spectacular.extensions import OpenApiFilterExtension, OpenApiAuthenticationExtension +from drf_spectacular.authentication import TokenScheme, SessionScheme from drf_spectacular.plumbing import build_parameter_type from drf_spectacular.utils import OpenApiParameter # https://drf-spectacular.readthedocs.io/en/latest/customization.html?highlight=OpenApiFilterExtension#step-5-extensions class OrganizationFilterExtension(OpenApiFilterExtension): - """Describe OrganizationFilterBackend filter""" + """ + Describe OrganizationFilterBackend filter + """ target_class = 'cvat.apps.iam.filters.OrganizationFilterBackend' priority = 1 @@ -36,19 +40,77 @@ def get_schema_operation_parameters(self, auto_schema, *args, **kwargs): ] class SignatureAuthenticationScheme(OpenApiAuthenticationExtension): + """ + Adds the signature auth method to schema + """ + target_class = 'cvat.apps.iam.authentication.SignatureAuthentication' - name = 'SignatureAuthentication' # name used in the schema + name = 'signatureAuth' # name used in the schema def get_security_definition(self, auto_schema): return { 'type': 'apiKey', 'in': 'query', 'name': 'sign', + 'description': 'Can be used to share URLs to private links', + } + +class TokenAuthenticationScheme(TokenScheme): + """ + Adds the token auth method to schema. The description includes extra info + comparing to what is generated by default. + """ + + name = 'tokenAuth' + priority = 0 + match_subclasses = True + + def get_security_requirement(self, auto_schema): + # These schemes must be used together + return {'sessionAuth': [], 'csrfAuth': [], self.name: []} + + def get_security_definition(self, auto_schema): + schema = super().get_security_definition(auto_schema) + schema['x-token-prefix'] = self.target.keyword + schema['description'] = textwrap.dedent(f""" + To authenticate using a token (or API key), you need to have 3 components in a request: + - the 'sessionid' cookie + - the 'csrftoken' cookie or 'X-CSRFTOKEN' header + - the 'Authentication' header with the '{self.target.keyword} ' prefix + + You can obtain an API key (the token) from the server response on + the basic auth request. + """) + return schema + +class CookieAuthenticationScheme(SessionScheme): + """ + This class adds csrftoken cookie into security sections. It must be used together with + the 'sessionid' cookie. + """ + + name = ['sessionAuth', 'csrfAuth'] + priority = 0 + + def get_security_requirement(self, auto_schema): + # These schemes cannot be used separately + return None + + def get_security_definition(self, auto_schema): + sessionid_schema = super().get_security_definition(auto_schema) + csrftoken_schema = { + 'type': 'apiKey', + 'in': 'cookie', + 'name': 'csrftoken', + 'description': 'Can be sent as a cookie or as the X-CSRFTOKEN header' } + return [sessionid_schema, csrftoken_schema] class CustomAutoSchema(AutoSchema): - # https://github.com/tfranzel/drf-spectacular/issues/111 - # Adds organization context parameters to all endpoints + """ + https://github.com/tfranzel/drf-spectacular/issues/111 + Adds organization context parameters to all endpoints + """ def get_override_parameters(self): return [ diff --git a/cvat/apps/iam/serializers.py b/cvat/apps/iam/serializers.py index c1ec0e4e5093..a805385431e4 100644 --- a/cvat/apps/iam/serializers.py +++ b/cvat/apps/iam/serializers.py @@ -4,8 +4,11 @@ # SPDX-License-Identifier: MIT from dj_rest_auth.registration.serializers import RegisterSerializer -from dj_rest_auth.serializers import PasswordResetSerializer +from dj_rest_auth.serializers import PasswordResetSerializer, LoginSerializer +from rest_framework.exceptions import ValidationError from rest_framework import serializers +from allauth.account import app_settings +from allauth.account.utils import filter_users_by_email from django.conf import settings @@ -38,3 +41,21 @@ def get_email_options(self): return { 'domain_override': domain } + +class LoginSerializerEx(LoginSerializer): + def get_auth_user_using_allauth(self, username, email, password): + # Authentication through email + if settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL: + return self._validate_email(email, password) + + # Authentication through username + if settings.ACCOUNT_AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME: + return self._validate_username(username, password) + + # Authentication through either username or email + if email: + users = filter_users_by_email(email) + if not users or len(users) > 1: + raise ValidationError('Unable to login with provided credentials') + + return self._validate_username_email(username, email, password) diff --git a/cvat/apps/organizations/models.py b/cvat/apps/organizations/models.py index dc3421fa6683..131be94d49e8 100644 --- a/cvat/apps/organizations/models.py +++ b/cvat/apps/organizations/models.py @@ -1,3 +1,8 @@ +# Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + from distutils.util import strtobool from django.conf import settings from django.db import models @@ -51,6 +56,9 @@ class Invitation(models.Model): owner = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL) membership = models.OneToOneField(Membership, on_delete=models.CASCADE) + def get_organization_id(self): + return self.membership.organization_id + def send(self): if not strtobool(settings.ORG_INVITATION_CONFIRM): self.accept(self.created_date) diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index f84bc89e472c..3e4da2f34bf7 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -1,4 +1,5 @@ # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -7,6 +8,7 @@ from django.utils.crypto import get_random_string from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view +from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin, CreateModelMixin from cvat.apps.iam.permissions import ( InvitationPermission, MembershipPermission, OrganizationPermission) @@ -25,7 +27,7 @@ '200': OrganizationReadSerializer, }), list=extend_schema( - summary='Method returns a paginated list of organizatins according to query parameters', + summary='Method returns a paginated list of organizations according to query parameters', responses={ '200': OrganizationReadSerializer(many=True), }), @@ -50,7 +52,13 @@ '204': OpenApiResponse(description='The organization has been deleted'), }) ) -class OrganizationViewSet(viewsets.ModelViewSet): +class OrganizationViewSet(viewsets.GenericViewSet, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + PartialUpdateModelMixin, + ): queryset = Organization.objects.all() search_fields = ('name', 'owner') filter_fields = list(search_fields) + ['id', 'slug'] @@ -110,8 +118,8 @@ class Meta: '204': OpenApiResponse(description='The membership has been deleted'), }) ) -class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, - mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): +class MembershipViewSet(mixins.RetrieveModelMixin, DestroyModelMixin, + mixins.ListModelMixin, PartialUpdateModelMixin, viewsets.GenericViewSet): queryset = Membership.objects.all() ordering = '-id' http_method_names = ['get', 'patch', 'delete', 'head', 'options'] @@ -165,7 +173,13 @@ def get_queryset(self): '204': OpenApiResponse(description='The invitation has been deleted'), }) ) -class InvitationViewSet(viewsets.ModelViewSet): +class InvitationViewSet(viewsets.GenericViewSet, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.UpdateModelMixin, + CreateModelMixin, + DestroyModelMixin, + ): queryset = Invitation.objects.all() http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] iam_organization_field = 'membership__organization' @@ -193,7 +207,7 @@ def perform_create(self, serializer): 'key': get_random_string(length=64), 'organization': self.request.iam_context['organization'] } - serializer.save(**extra_kwargs) + super().perform_create(serializer, **extra_kwargs) def perform_update(self, serializer): if 'accepted' in self.request.query_params: diff --git a/cvat/apps/webhooks/__init__.py b/cvat/apps/webhooks/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/webhooks/apps.py b/cvat/apps/webhooks/apps.py new file mode 100644 index 000000000000..ac15da359b10 --- /dev/null +++ b/cvat/apps/webhooks/apps.py @@ -0,0 +1,12 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from django.apps import AppConfig + + +class WebhooksConfig(AppConfig): + name = "cvat.apps.webhooks" + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/cvat/apps/webhooks/event_type.py b/cvat/apps/webhooks/event_type.py new file mode 100644 index 000000000000..4c74810f54dc --- /dev/null +++ b/cvat/apps/webhooks/event_type.py @@ -0,0 +1,58 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from .models import WebhookTypeChoice + + +def event_name(action, resource): + return f"{action}:{resource}" + + +class Events: + RESOURCES = { + "project": ["create", "update", "delete"], + "task": ["create", "update", "delete"], + "issue": ["create", "update", "delete"], + "comment": ["create", "update", "delete"], + "invitation": ["create", "delete"], # TO-DO: implement invitation_updated, + "membership": ["update", "delete"], + "job": ["update"], + "organization": ["update"], + } + + @classmethod + def select(cls, resources): + return [ + f"{event_name(action, resource)}" + for resource in resources + for action in cls.RESOURCES.get(resource, []) + ] + + +class EventTypeChoice: + @classmethod + def choices(cls): + return sorted((val, val.upper()) for val in AllEvents.events) + + +class AllEvents: + webhook_type = "all" + events = list( + event_name(action, resource) + for resource, actions in Events.RESOURCES.items() + for action in actions + ) + + +class ProjectEvents: + webhook_type = WebhookTypeChoice.PROJECT + events = [event_name("update", "project")] \ + + Events.select(["job", "task", "issue", "comment"]) + + +class OrganizationEvents: + webhook_type = WebhookTypeChoice.ORGANIZATION + events = [event_name("update", "organization")] \ + + Events.select(["membership", "invitation", "project"]) \ + + ProjectEvents.events diff --git a/cvat/apps/webhooks/migrations/0001_initial.py b/cvat/apps/webhooks/migrations/0001_initial.py new file mode 100644 index 000000000000..fe8f296b0514 --- /dev/null +++ b/cvat/apps/webhooks/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 3.2.15 on 2022-09-19 08:26 + +import cvat.apps.webhooks.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('engine', '0060_alter_label_parent'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('target_url', models.URLField()), + ('description', models.CharField(blank=True, default='', max_length=128)), + ('events', models.CharField(default='', max_length=4096)), + ('type', models.CharField(choices=[('organization', 'ORGANIZATION'), ('project', 'PROJECT')], max_length=16)), + ('content_type', models.CharField(choices=[('application/json', 'JSON')], default=cvat.apps.webhooks.models.WebhookContentTypeChoice['JSON'], max_length=64)), + ('secret', models.CharField(blank=True, default='', max_length=64)), + ('is_active', models.BooleanField(default=True)), + ('enable_ssl', models.BooleanField(default=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='engine.project')), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.CreateModel( + name='WebhookDelivery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(max_length=64)), + ('status_code', models.CharField(max_length=128, null=True)), + ('redelivery', models.BooleanField(default=False)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('changed_fields', models.CharField(default='', max_length=4096)), + ('request', models.JSONField(default=dict)), + ('response', models.JSONField(default=dict)), + ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='webhooks.webhook')), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddConstraint( + model_name='webhook', + constraint=models.CheckConstraint(check=models.Q(models.Q(('project_id__isnull', False), ('type', 'project')), models.Q(('organization_id__isnull', False), ('project_id__isnull', True), ('type', 'organization')), _connector='OR'), name='webhooks_project_or_organization'), + ), + ] diff --git a/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py b/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py new file mode 100644 index 000000000000..fd1a2397d249 --- /dev/null +++ b/cvat/apps/webhooks/migrations/0002_alter_webhookdelivery_status_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2022-09-27 12:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='webhookdelivery', + name='status_code', + field=models.IntegerField(choices=[('CONTINUE', 100), ('SWITCHING_PROTOCOLS', 101), ('PROCESSING', 102), ('OK', 200), ('CREATED', 201), ('ACCEPTED', 202), ('NON_AUTHORITATIVE_INFORMATION', 203), ('NO_CONTENT', 204), ('RESET_CONTENT', 205), ('PARTIAL_CONTENT', 206), ('MULTI_STATUS', 207), ('ALREADY_REPORTED', 208), ('IM_USED', 226), ('MULTIPLE_CHOICES', 300), ('MOVED_PERMANENTLY', 301), ('FOUND', 302), ('SEE_OTHER', 303), ('NOT_MODIFIED', 304), ('USE_PROXY', 305), ('TEMPORARY_REDIRECT', 307), ('PERMANENT_REDIRECT', 308), ('BAD_REQUEST', 400), ('UNAUTHORIZED', 401), ('PAYMENT_REQUIRED', 402), ('FORBIDDEN', 403), ('NOT_FOUND', 404), ('METHOD_NOT_ALLOWED', 405), ('NOT_ACCEPTABLE', 406), ('PROXY_AUTHENTICATION_REQUIRED', 407), ('REQUEST_TIMEOUT', 408), ('CONFLICT', 409), ('GONE', 410), ('LENGTH_REQUIRED', 411), ('PRECONDITION_FAILED', 412), ('REQUEST_ENTITY_TOO_LARGE', 413), ('REQUEST_URI_TOO_LONG', 414), ('UNSUPPORTED_MEDIA_TYPE', 415), ('REQUESTED_RANGE_NOT_SATISFIABLE', 416), ('EXPECTATION_FAILED', 417), ('MISDIRECTED_REQUEST', 421), ('UNPROCESSABLE_ENTITY', 422), ('LOCKED', 423), ('FAILED_DEPENDENCY', 424), ('UPGRADE_REQUIRED', 426), ('PRECONDITION_REQUIRED', 428), ('TOO_MANY_REQUESTS', 429), ('REQUEST_HEADER_FIELDS_TOO_LARGE', 431), ('UNAVAILABLE_FOR_LEGAL_REASONS', 451), ('INTERNAL_SERVER_ERROR', 500), ('NOT_IMPLEMENTED', 501), ('BAD_GATEWAY', 502), ('SERVICE_UNAVAILABLE', 503), ('GATEWAY_TIMEOUT', 504), ('HTTP_VERSION_NOT_SUPPORTED', 505), ('VARIANT_ALSO_NEGOTIATES', 506), ('INSUFFICIENT_STORAGE', 507), ('LOOP_DETECTED', 508), ('NOT_EXTENDED', 510), ('NETWORK_AUTHENTICATION_REQUIRED', 511)], default=None, null=True), + ), + ] diff --git a/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py b/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py new file mode 100644 index 000000000000..676f03a2dc9b --- /dev/null +++ b/cvat/apps/webhooks/migrations/0003_alter_webhookdelivery_status_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2022-10-11 09:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0002_alter_webhookdelivery_status_code'), + ] + + operations = [ + migrations.AlterField( + model_name='webhookdelivery', + name='status_code', + field=models.PositiveIntegerField(default=None, null=True), + ), + ] diff --git a/cvat/apps/webhooks/migrations/__init__.py b/cvat/apps/webhooks/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cvat/apps/webhooks/models.py b/cvat/apps/webhooks/models.py new file mode 100644 index 000000000000..44885ffd0c19 --- /dev/null +++ b/cvat/apps/webhooks/models.py @@ -0,0 +1,103 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from enum import Enum + +from django.contrib.auth.models import User +from django.db import models + +from cvat.apps.engine.models import Project +from cvat.apps.organizations.models import Organization + + +class WebhookTypeChoice(str, Enum): + ORGANIZATION = "organization" + PROJECT = "project" + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + def __str__(self): + return self.value + + +class WebhookContentTypeChoice(str, Enum): + JSON = "application/json" + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + def __str__(self): + return self.value + + +class Webhook(models.Model): + target_url = models.URLField() + description = models.CharField(max_length=128, default="", blank=True) + + events = models.CharField(max_length=4096, default="") + type = models.CharField(max_length=16, choices=WebhookTypeChoice.choices()) + content_type = models.CharField( + max_length=64, + choices=WebhookContentTypeChoice.choices(), + default=WebhookContentTypeChoice.JSON, + ) + secret = models.CharField(max_length=64, blank=True, default="") + + is_active = models.BooleanField(default=True) + enable_ssl = models.BooleanField(default=True) + + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + + owner = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + project = models.ForeignKey( + Project, null=True, on_delete=models.CASCADE, related_name="+" + ) + organization = models.ForeignKey( + Organization, null=True, on_delete=models.CASCADE, related_name="+" + ) + + class Meta: + default_permissions = () + constraints = [ + models.CheckConstraint( + name="webhooks_project_or_organization", + check=( + models.Q( + type=WebhookTypeChoice.PROJECT.value, project_id__isnull=False + ) + | models.Q( + type=WebhookTypeChoice.ORGANIZATION.value, + project_id__isnull=True, + organization_id__isnull=False, + ) + ), + ) + ] + + +class WebhookDelivery(models.Model): + webhook = models.ForeignKey( + Webhook, on_delete=models.CASCADE, related_name="deliveries" + ) + event = models.CharField(max_length=64) + + status_code = models.PositiveIntegerField(null=True, default=None) + redelivery = models.BooleanField(default=False) + + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + + changed_fields = models.CharField(max_length=4096, default="") + + request = models.JSONField(default=dict) + response = models.JSONField(default=dict) + + class Meta: + default_permissions = () diff --git a/cvat/apps/webhooks/serializers.py b/cvat/apps/webhooks/serializers.py new file mode 100644 index 000000000000..d06a5edf7539 --- /dev/null +++ b/cvat/apps/webhooks/serializers.py @@ -0,0 +1,146 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from .event_type import EventTypeChoice, ProjectEvents, OrganizationEvents +from .models import ( + Webhook, + WebhookContentTypeChoice, + WebhookTypeChoice, + WebhookDelivery, +) +from rest_framework import serializers +from cvat.apps.engine.serializers import BasicUserSerializer, WriteOnceMixin + + +class EventTypeValidator: + requires_context = True + + def get_webhook_type(self, attrs, serializer): + if serializer.instance is not None: + return serializer.instance.type + return attrs.get("type") + + def __call__(self, attrs, serializer): + if attrs.get("events") is not None: + webhook_type = self.get_webhook_type(attrs, serializer) + events = set(EventTypesSerializer().to_representation(attrs["events"])) + if ( + webhook_type == WebhookTypeChoice.PROJECT + and not events.issubset(set(ProjectEvents.events)) + ) or ( + webhook_type == WebhookTypeChoice.ORGANIZATION + and not events.issubset(set(OrganizationEvents.events)) + ): + raise serializers.ValidationError( + f"Invalid events list for {webhook_type} webhook" + ) + + +class EventTypesSerializer(serializers.MultipleChoiceField): + def __init__(self, *args, **kwargs): + super().__init__(choices=EventTypeChoice.choices(), *args, **kwargs) + + def to_representation(self, value): + if isinstance(value, list): + return sorted(super().to_representation(value)) + return sorted(list(super().to_representation(value.split(",")))) + + def to_internal_value(self, data): + return ",".join(super().to_internal_value(data)) + + +class EventsSerializer(serializers.Serializer): + webhook_type = serializers.ChoiceField(choices=WebhookTypeChoice.choices()) + events = EventTypesSerializer() + + +class WebhookReadSerializer(serializers.ModelSerializer): + owner = BasicUserSerializer(read_only=True, required=False) + + events = EventTypesSerializer(read_only=True) + + type = serializers.ChoiceField(choices=WebhookTypeChoice.choices()) + content_type = serializers.ChoiceField(choices=WebhookContentTypeChoice.choices()) + + last_status = serializers.IntegerField( + source="deliveries.last.status_code", read_only=True + ) + + last_delivery_date = serializers.DateTimeField( + source="deliveries.last.updated_date", read_only=True + ) + + class Meta: + model = Webhook + fields = ( + "id", + "url", + "target_url", + "description", + "type", + "content_type", + "is_active", + "enable_ssl", + "created_date", + "updated_date", + "owner", + "project", + "organization", + "events", + "last_status", + "last_delivery_date", + ) + read_only_fields = fields + + +class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): + events = EventTypesSerializer(write_only=True) + + project_id = serializers.IntegerField( + write_only=True, allow_null=True, required=False + ) + + def to_representation(self, instance): + serializer = WebhookReadSerializer(instance, context=self.context) + return serializer.data + + class Meta: + model = Webhook + fields = ( + "target_url", + "description", + "type", + "content_type", + "secret", + "is_active", + "enable_ssl", + "project_id", + "events", + ) + write_once_fields = ("type", "project_id") + validators = [EventTypeValidator()] + + def create(self, validated_data): + db_webhook = Webhook.objects.create(**validated_data) + return db_webhook + + +class WebhookDeliveryReadSerializer(serializers.ModelSerializer): + webhook_id = serializers.IntegerField(read_only=True) + + class Meta: + model = WebhookDelivery + fields = ( + "id", + "webhook_id", + "event", + "status_code", + "redelivery", + "created_date", + "updated_date", + "changed_fields", + "request", + "response", + ) + read_only_fields = fields diff --git a/cvat/apps/webhooks/signals.py b/cvat/apps/webhooks/signals.py new file mode 100644 index 000000000000..ee152bb82cfc --- /dev/null +++ b/cvat/apps/webhooks/signals.py @@ -0,0 +1,221 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +import hashlib +import hmac +from http import HTTPStatus +import json + +import django_rq +import requests +from django.dispatch import Signal, receiver + +from cvat.apps.engine.models import Project +from cvat.apps.engine.serializers import BasicUserSerializer +from cvat.apps.organizations.models import Organization + +from .event_type import EventTypeChoice, event_name +from .models import Webhook, WebhookDelivery, WebhookTypeChoice + +WEBHOOK_TIMEOUT = 10 +RESPONSE_SIZE_LIMIT = 1 * 1024 * 1024 # 1 MB + +signal_update = Signal() +signal_create = Signal() +signal_delete = Signal() +signal_redelivery = Signal() +signal_ping = Signal() + + +def send_webhook(webhook, payload, delivery): + headers = {} + if webhook.secret: + headers["X-Signature-256"] = ( + "sha256=" + + hmac.new( + webhook.secret.encode("utf-8"), + (json.dumps(payload) + "\n").encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + ) + + response_body = None + try: + response = requests.post( + webhook.target_url, + json=payload, + verify=webhook.enable_ssl, + headers=headers, + timeout=WEBHOOK_TIMEOUT, + stream=True, + ) + status_code = response.status_code + response_body = response.raw.read(RESPONSE_SIZE_LIMIT + 1, decode_content=True) + except requests.ConnectionError: + status_code = HTTPStatus.BAD_GATEWAY + except requests.Timeout: + status_code = HTTPStatus.GATEWAY_TIMEOUT + + setattr(delivery, "status_code", status_code) + if response_body is not None and len(response_body) < RESPONSE_SIZE_LIMIT + 1: + setattr(delivery, "response", response_body.decode("utf-8")) + + delivery.save() + + +def add_to_queue(webhook, payload, redelivery=False): + delivery = WebhookDelivery.objects.create( + webhook_id=webhook.id, + event=payload["event"], + status_code=None, + changed_fields=",".join(list(payload.get("before_update", {}).keys())), + redelivery=redelivery, + request=payload, + response="", + ) + + queue = django_rq.get_queue("webhooks") + queue.enqueue_call(func=send_webhook, args=(webhook, payload, delivery)) + + return delivery + + +def select_webhooks(project_id, org_id, event): + selected_webhooks = [] + if org_id is not None: + webhooks = Webhook.objects.filter( + is_active=True, + events__contains=event, + type=WebhookTypeChoice.ORGANIZATION, + organization=org_id, + ) + selected_webhooks += list(webhooks) + + if project_id is not None: + webhooks = Webhook.objects.filter( + is_active=True, + events__contains=event, + type=WebhookTypeChoice.PROJECT, + organization=org_id, + project=project_id, + ) + selected_webhooks += list(webhooks) + + return selected_webhooks + + +def payload(data, request): + return { + **data, + "sender": BasicUserSerializer(request.user, context={"request": request}).data, + } + + +def project_id(instance): + if isinstance(instance, Project): + return instance.id + + try: + pid = getattr(instance, "project_id", None) + if pid is None: + return instance.get_project_id() + return pid + except Exception: + return None + + +def organization_id(instance): + if isinstance(instance, Organization): + return instance.id + + try: + oid = getattr(instance, "organization_id", None) + if oid is None: + return instance.get_organization_id() + return oid + except Exception: + return None + + +@receiver(signal_update) +def update(sender, instance=None, old_values=None, **kwargs): + event = event_name("update", sender.basename) + if event not in map(lambda a: a[0], EventTypeChoice.choices()): + return + + serializer = sender.get_serializer_class()( + instance=instance, context={"request": sender.request} + ) + + pid = project_id(instance) + oid = organization_id(instance) + + if not any((oid, pid)): + return + + data = { + "event": event, + sender.basename: serializer.data, + "before_update": old_values, + } + + for webhook in select_webhooks(pid, oid, event): + data.update({"webhook_id": webhook.id}) + add_to_queue(webhook, payload(data, sender.request)) + + +@receiver(signal_create) +def resource_created(sender, instance=None, **kwargs): + event = event_name("create", sender.basename) + if event not in map(lambda a: a[0], EventTypeChoice.choices()): + return + + pid = project_id(instance) + oid = organization_id(instance) + if not any((oid, pid)): + return + + serializer = sender.get_serializer_class()( + instance=instance, context={"request": sender.request} + ) + + data = {"event": event, sender.basename: serializer.data} + + for webhook in select_webhooks(pid, oid, event): + data.update({"webhook_id": webhook.id}) + add_to_queue(webhook, payload(data, sender.request)) + + +@receiver(signal_delete) +def resource_deleted(sender, instance=None, **kwargs): + event = event_name("delete", sender.basename) + if event not in map(lambda a: a[0], EventTypeChoice.choices()): + return + + pid = project_id(instance) + oid = organization_id(instance) + if not any((oid, pid)): + return + + serializer = sender.get_serializer_class()( + instance=instance, context={"request": sender.request} + ) + + data = {"event": event, sender.basename: serializer.data} + + for webhook in select_webhooks(pid, oid, event): + data.update({"webhook_id": webhook.id}) + add_to_queue(webhook, payload(data, sender.request)) + + +@receiver(signal_redelivery) +def redelivery(sender, data=None, **kwargs): + add_to_queue(sender.get_object(), data, redelivery=True) + + +@receiver(signal_ping) +def ping(sender, serializer, **kwargs): + data = {"event": "ping", "webhook": serializer.data} + delivery = add_to_queue(serializer.instance, payload(data, sender.request)) + return delivery diff --git a/cvat/apps/webhooks/urls.py b/cvat/apps/webhooks/urls.py new file mode 100644 index 000000000000..c309df746f96 --- /dev/null +++ b/cvat/apps/webhooks/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from rest_framework.routers import DefaultRouter +from .views import WebhookViewSet + +router = DefaultRouter(trailing_slash=False) +router.register("webhooks", WebhookViewSet) + +urlpatterns = router.urls diff --git a/cvat/apps/webhooks/views.py b/cvat/apps/webhooks/views.py new file mode 100644 index 000000000000..b39e1bc018a9 --- /dev/null +++ b/cvat/apps/webhooks/views.py @@ -0,0 +1,195 @@ +# Copyright (C) 2022 CVAT.ai Corporation +# +# SPDX-License-Identifier: MIT + +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + OpenApiTypes, + extend_schema, + extend_schema_view, +) +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import SAFE_METHODS +from rest_framework.response import Response + +from cvat.apps.iam.permissions import WebhookPermission + +from .event_type import AllEvents, OrganizationEvents, ProjectEvents +from .models import Webhook, WebhookDelivery, WebhookTypeChoice +from .serializers import ( + EventsSerializer, + WebhookDeliveryReadSerializer, + WebhookReadSerializer, + WebhookWriteSerializer, +) +from .signals import signal_ping, signal_redelivery + + +@extend_schema(tags=["webhooks"]) +@extend_schema_view( + retrieve=extend_schema( + summary="Method returns details of a webhook", + responses={"200": WebhookReadSerializer}, + ), + list=extend_schema( + summary="Method returns a paginated list of webhook according to query parameters", + responses={"200": WebhookReadSerializer(many=True)}, + ), + update=extend_schema( + summary="Method updates a webhook by id", + responses={"200": WebhookWriteSerializer}, + ), + partial_update=extend_schema( + summary="Methods does a partial update of chosen fields in a webhook", + responses={"200": WebhookWriteSerializer}, + ), + create=extend_schema( + summary="Method creates a webhook", responses={"201": WebhookWriteSerializer} + ), + destroy=extend_schema( + summary="Method deletes a webhook", + responses={"204": OpenApiResponse(description="The webhook has been deleted")}, + ), +) +class WebhookViewSet(viewsets.ModelViewSet): + queryset = Webhook.objects.all() + ordering = "-id" + http_method_names = ["get", "post", "delete", "patch", "put"] + + search_fields = ("target_url", "owner", "type", "description") + filter_fields = list(search_fields) + ["id", "project_id", "updated_date"] + ordering_fields = filter_fields + lookup_fields = {"owner": "owner__username"} + iam_organization_field = "organization" + + def get_serializer_class(self): + if self.request.path.endswith("redelivery") or self.request.path.endswith( + "ping" + ): + return None + else: + if self.request.method in SAFE_METHODS: + return WebhookReadSerializer + else: + return WebhookWriteSerializer + + def get_queryset(self): + queryset = super().get_queryset() + if self.action == "list": + perm = WebhookPermission.create_scope_list(self.request) + queryset = perm.filter(queryset) + + return queryset + + def perform_create(self, serializer): + serializer.save( + owner=self.request.user, + organization=self.request.iam_context["organization"], + ) + + @extend_schema( + summary="Method return a list of available webhook events", + parameters=[ + OpenApiParameter( + "type", + description="Type of webhook", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + required=False, + ) + ], + responses={"200": OpenApiResponse(EventsSerializer)}, + ) + @action(detail=False, methods=["GET"], serializer_class=EventsSerializer) + def events(self, request): + webhook_type = request.query_params.get("type", "all") + events = None + if webhook_type == "all": + events = AllEvents + elif webhook_type == WebhookTypeChoice.PROJECT: + events = ProjectEvents + elif webhook_type == WebhookTypeChoice.ORGANIZATION: + events = OrganizationEvents + + if events is None: + return Response( + "Incorrect value of type parameter", status=status.HTTP_400_BAD_REQUEST + ) + + return Response(EventsSerializer().to_representation(events)) + + @extend_schema( + summary="Method return a list of deliveries for a specific webhook", + responses={"200": WebhookDeliveryReadSerializer(many=True)}, + ) + @action( + detail=True, methods=["GET"], serializer_class=WebhookDeliveryReadSerializer + ) + def deliveries(self, request, pk): + self.get_object() + queryset = WebhookDelivery.objects.filter(webhook_id=pk).order_by( + "-updated_date" + ) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = WebhookDeliveryReadSerializer( + page, many=True, context={"request": request} + ) + return self.get_paginated_response(serializer.data) + + serializer = WebhookDeliveryReadSerializer( + queryset, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @extend_schema( + summary="Method return a specific delivery for a specific webhook", + responses={"200": WebhookDeliveryReadSerializer}, + ) + @action( + detail=True, + methods=["GET"], + url_path=r"deliveries/(?P\d+)", + serializer_class=WebhookDeliveryReadSerializer, + ) + def retrieve_delivery(self, request, pk, delivery_id): + self.get_object() + queryset = WebhookDelivery.objects.get(webhook_id=pk, id=delivery_id) + serializer = WebhookDeliveryReadSerializer( + queryset, context={"request": request} + ) + return Response(serializer.data) + + @extend_schema(summary="Method redeliver a specific webhook delivery") + @action( + detail=True, + methods=["POST"], + url_path=r"deliveries/(?P\d+)/redelivery", + ) + def redelivery(self, request, pk, delivery_id): + delivery = WebhookDelivery.objects.get(webhook_id=pk, id=delivery_id) + signal_redelivery.send(sender=self, data=delivery.request) + + # Questionable: should we provide a body for this response? + return Response({}) + + @extend_schema( + summary="Method send ping webhook", + responses={"200": WebhookDeliveryReadSerializer}, + ) + @action( + detail=True, methods=["POST"], serializer_class=WebhookDeliveryReadSerializer + ) + def ping(self, request, pk): + instance = self.get_object() + serializer = WebhookReadSerializer(instance, context={"request": request}) + + delivery = signal_ping.send(sender=self, serializer=serializer)[0][1] + serializer = WebhookDeliveryReadSerializer( + delivery, context={"request": request} + ) + return Response(serializer.data) diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 54178cc96e07..4f52cfb5807f 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -45,9 +45,7 @@ diskcache==5.0.2 boto3==1.17.61 azure-storage-blob==12.13.0 google-cloud-storage==1.42.0 -# --no-binary=datumaro: workaround for pip to install -# opencv-headless instead of regular opencv, to actually run setup script -datumaro==0.2.0 --no-binary=datumaro +git+https://github.com/cvat-ai/datumaro.git@d9f492003ac57ce9c511c3079f67861e177ff134 urllib3>=1.26.5 # not directly required, pinned by Snyk to avoid a vulnerability natsort==8.0.0 mistune>=2.0.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 9cac8971d2fb..a783941a21b6 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -23,6 +23,7 @@ import mimetypes from corsheaders.defaults import default_headers from distutils.util import strtobool +from cvat import __version__ mimetypes.add_type("application/wasm", ".wasm", True) @@ -128,6 +129,7 @@ def add_ssh_keys(): 'cvat.apps.restrictions', 'cvat.apps.lambda_manager', 'cvat.apps.opencv', + 'cvat.apps.webhooks', ] SITE_ID = 1 @@ -175,7 +177,7 @@ def add_ssh_keys(): 'rest_framework.throttling.AnonRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { - 'anon': '100/minute', + 'anon': '150/minute', }, 'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata', 'DEFAULT_SCHEMA_CLASS': 'cvat.apps.iam.schema.CustomAutoSchema', @@ -186,6 +188,7 @@ def add_ssh_keys(): } REST_AUTH_SERIALIZERS = { + 'LOGIN_SERIALIZER': 'cvat.apps.iam.serializers.LoginSerializerEx', 'PASSWORD_RESET_SERIALIZER': 'cvat.apps.iam.serializers.PasswordResetSerializerEx', } @@ -256,6 +259,7 @@ def add_ssh_keys(): # https://github.com/pennersr/django-allauth ACCOUNT_EMAIL_VERIFICATION = 'none' +ACCOUNT_AUTHENTICATION_METHOD = 'username_email' # set UI url to redirect after a successful e-mail confirmation #changed from '/auth/login' to '/auth/email-confirmation' for email confirmation message ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/auth/email-confirmation' @@ -277,6 +281,12 @@ def add_ssh_keys(): 'PORT': 6379, 'DB': 0, 'DEFAULT_TIMEOUT': '24h' + }, + 'webhooks': { + 'HOST': 'localhost', + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': '1h' } } @@ -510,7 +520,7 @@ def add_ssh_keys(): # Statically set schema version. May also be an empty string. When used together with # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests. # Set VERSION to None if only the request version should be rendered. - 'VERSION': '2.1.0', + 'VERSION': __version__, 'CONTACT': { 'name': 'CVAT.ai team', 'url': 'https://github.com/cvat-ai/cvat', diff --git a/cvat/settings/email_settings.py b/cvat/settings/email_settings.py index c7fdaa4e27be..ca84a57559ae 100644 --- a/cvat/settings/email_settings.py +++ b/cvat/settings/email_settings.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # Copyright (C) 2020-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT @@ -8,7 +9,7 @@ # https://github.com/pennersr/django-allauth -ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_AUTHENTICATION_METHOD = 'username_email' ACCOUNT_CONFIRM_EMAIL_ON_GET = True ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_VERIFICATION = 'mandatory' diff --git a/cvat/urls.py b/cvat/urls.py index 5eb86c8fe192..09eed3e9cfea 100644 --- a/cvat/urls.py +++ b/cvat/urls.py @@ -40,5 +40,8 @@ if apps.is_installed('cvat.apps.opencv'): urlpatterns.append(path('opencv/', include('cvat.apps.opencv.urls'))) +if apps.is_installed('cvat.apps.webhooks'): + urlpatterns.append(path('api/', include('cvat.apps.webhooks.urls'))) + if apps.is_installed('silk'): urlpatterns.append(path('profiler/', include('silk.urls'))) diff --git a/docker-compose.yml b/docker-compose.yml index de616828f49d..80605dd43023 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,27 @@ services: networks: - cvat + cvat_worker_webhooks: + container_name: cvat_worker_webhooks + image: cvat/server:${CVAT_VERSION:-dev} + restart: always + depends_on: + - cvat_redis + - cvat_db + - cvat_opa + environment: + CVAT_REDIS_HOST: 'cvat_redis' + CVAT_POSTGRES_HOST: 'cvat_db' + no_proxy: elasticsearch,kibana,logstash,nuclio,opa,${no_proxy} + NUMPROCS: 1 + command: -c supervisord/worker.webhooks.conf + volumes: + - cvat_data:/home/django/data + - cvat_keys:/home/django/keys + - cvat_logs:/home/django/logs + networks: + - cvat + cvat_ui: container_name: cvat_ui image: cvat/ui:${CVAT_VERSION:-dev} diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index 1e3f2e5393d7..27083aaa226f 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.3.0 +version: 0.3.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/helm-chart/README.md b/helm-chart/README.md index a01483140b49..d9ba44af655a 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -1,231 +1,2 @@ -# CVAT chart manual -- [CVAT chart manual](#cvat-chart-manual) - - [Prerequisites](#prerequisites) - - [Installing dependencies](#installing-dependencies) - - [Optional steps](#optional-steps) - - [Configuration](#configuration) - - [Postgresql password?](#postgresql-password) - - [(Optional) Enable Auto annotation feature](#optional-enable-auto-annotation-feature) - - [(Optional) Enable Analytics](#optional-enable-analytics) - - [Deployment](#deployment) - - [With overrides:](#with-overrides) - - [Without overrides:](#without-overrides) - - [Post-deployment configuration](#post-deployment-configuration) - - [How to create superuser?](#how-to-create-superuser) - - [FAQ](#faq) - - [What is kubernetes and how it is working?](#what-is-kubernetes-and-how-it-is-working) - - [What is helm and how it is working?](#what-is-helm-and-how-it-is-working) - - [How to setup Minikube?](#how-to-setup-minikube) - - [How to understand what diff will be inflicted by 'helm upgrade'?](#how-to-understand-what-diff-will-be-inflicted-by-helm-upgrade) - - [I want to use my own postgresql/redis with your chart.](#i-want-to-use-my-own-postgresqlredis-with-your-chart) - - [I want to override some settings in values.yaml.](#i-want-to-override-some-settings-in-valuesyaml) - - [Why you used external charts to provide redis and postgres?](#why-you-used-external-charts-to-provide-redis-and-postgres) - -## Prerequisites -1. Installed and configured [kubernetes](https://kubernetes.io/) cluster. If you do not already have a cluster, - you can create one by using [Minikube](https://github.com/kubernetes/minikube/). [How to setup Minikube](#how-to-setup-minikube). -1. Installed [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) -1. Installed [Helm](https://helm.sh/). -1. Installed [dependencies](#installing-dependencies) - -### Installing dependencies -To install and/or update run: -```shell -helm dependency update -``` - -### Optional steps -1. Ingress configuration for the Traefik ingress controller is enabled by default. - - Note for Minikube use: - - because the Traefik creates its main service with `Loadbalanser` type, - which involve the assignment of externalIP by Cloud, what never happens on Minikube, - you need to explicitly set the externalIP address for the traefic service. - Add the following to `values.override.yaml` file: - ```yaml - traefik: - service: - externalIPs: - - "your minikube IP (can be obtained with `minicube ip` command)" - ``` - - Also ensure that your CVAT ingress appears on your hosts file (/etc/hosts). - You can do this by running this command: - `cvat.local` is default domainname, you can override it via `values.override.yaml`. - ```shell - echo "$(minikube ip) cvat.local" | sudo tee -a /etc/hosts - ``` - -## Configuration -1. Create `values.override.yaml` file inside `helm-chart` directory. -1. Fill `values.override.yaml` with new parameters for chart. -1. Override [postgresql password](#postgresql-password) -1. Create a rules.tar.gz archive containing all OPA rules inside this `helm-chart` directory. - ```shell - find ../cvat/apps/iam/rules -name "*.rego" -and ! -name '*test*' -exec basename {} \; | tar -czf rules.tar.gz -C ../cvat/apps/iam/rules/ -T - - ``` - -### Postgresql password? -Put below into your `values.override.yaml` -```yaml -postgresql: - secret: - password: - postgres_password: - replication_password: -``` -Or create your own secret and use it with: -```yaml -postgresql: - global: - postgresql: - existingSecret: -``` - -### (Optional) Enable Auto annotation feature - -Before starting, ensure that the following prerequisites are met: -- The Nuclio [CLI (nuctl)](https://nuclio.io/docs/latest/reference/nuctl/nuctl/) is installed. - To install the CLI, simply [download](https://github.com/nuclio/nuclio/releases) - the appropriate CLI version to your installation machine. - -1. Set `nuclio.enabled: true` in your `values.override.yaml` -1. Run `helm dependency update` in `helm-chart` directory -1. Because Nuclio functions are images that need to be pushed and pulled to/from the registry, - you need to configure credentials to pull from your preferable registry with the following settings: - Options: - - `values.override.yaml` file: - ```yaml - registry: - loginUrl: someurl - credentials: - username: someuser - password: somepass - ``` - - Or you can create a secret with credentials as described in the [guide](https://nuclio.io/docs/latest/setup/k8s/running-in-production-k8s/#the-preferred-deployment-method) - and set `registry.secretName=your-registry-credentials-secret-name` in the `values.override.yaml` file. - - - In the case of using Minikube, you can run a local unsecured registry with minikube add-ons: - ```shell - minikube addons enable registry - minikube addons enable registry-aliases - ``` - Before Docker container images can be pushed to your newly created unsecure registry, - you need to add its address (`$(minikube ip):5000`) to the list of unsecure registries to - instruct Docker to accept working against it: - follow the instructions in the [Docker documentation](https://docs.docker.com/registry/insecure/#deploy-a-plain-http-registry) - - You might also need to log into your registry account (docker login) - on the installation machine before running the deployment command. - -1. Create cvat project: - ```shell - nuctl --namespace create project cvat - ``` -1. Finaly deploy the fuction, i.e.: - - using minikube registry: - ```shell - nuctl deploy --project-name cvat --path serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio --registry $(minikube ip):5000 --run-registry registry.minikube - ``` - - using Docker hub: - ```shell - nuctl deploy --project-name cvat --path serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio --registry docker.io/your_username - ``` - -### (Optional) Enable Analytics - -1. Set `analytics.enabled: true` in your `values.override.yaml` -1. Run `helm dependency update` in `helm-chart` directory -1. Since custom images are required here, you will need to create them yourself - and push them to your preferred docker registry. - You might also need to log into your registry account (docker login) - on the installation machine before running the push command. - How to set up local registry when using Minikube see [previous section](#how_to_enable_auto_annotation_feature) - - - Let's build custom elasticsearch, logstash and kibana images with the following command - ```shell - docker-compose -f docker-compose.yml -f components/analytics/docker-compose.analytics.yml build - ``` - - - Tag images: - ```shell - docker tag cvat_kibana:latest /cvat_kibana:latest - docker tag cvat_elasticsearch:latest /cvat_elasticsearch:latest - docker tag cvat_logstash:latest /cvat_logstash:latest - ``` - - - Push to registry - ```shell - docker push /cvat_kibana:latest - docker push /cvat_elasticsearch:latest - docker push /cvat_logstash:latest - ``` - - - Add corresponding settings into `values.override.yaml`, i.e. for minikube registry: - ```yaml - logstash: - image: "registry.minikube/cvat_logstash" - imageTag: "latest" - - elasticsearch: - image: "registry.minikube/cvat_elasticsearch" - imageTag: "latest" - - kibana: - image: "registry.minikube/cvat_kibana" - imageTag: "latest" - ``` - - - Deploy - ```shell - helm upgrade --namespace --install ./helm-chart -f ./helm-chart/values.yaml -f values.override.yaml - ``` - -## Deployment -Make sure you are using correct kubernetes context. You can check it with `kubectl config current-context`. - -> **Warning:** The k8s service name of Open Policy Agent is fixed to opa by default. -> This is done to be compatible with CVAT 2.0 but limits this helm chart to a single release per namespace. -> The OPA url currently can´t be set as an environment variable. -> As soon as this is possible you can set cvat.opa.composeCompatibleServiceName -> to false in your value.override.yaml and configure the opa url as additional env. - -Execute following command from repo root directory -### With overrides: -```helm upgrade -n -i --create-namespace ./helm-chart -f ./helm-chart/values.yaml -f ./helm-chart/values.override.yaml``` - -### Without overrides: -```helm upgrade -n -i --create-namespace ./helm-chart -f ./helm-chart/values.yaml``` - -## Post-deployment configuration - -1. Create [super user](#how-to-create-superuser) - -### How to create superuser? -```sh -HELM_RELEASE_NAMESPACE="" &&\ -HELM_RELEASE_NAME="" &&\ -BACKEND_POD_NAME=$(kubectl get pod --namespace $HELM_RELEASE_NAMESPACE -l tier=backend,app.kubernetes.io/instance=$HELM_RELEASE_NAME -o jsonpath='{.items[0].metadata.name}') &&\ -kubectl exec -it --namespace $HELM_RELEASE_NAMESPACE $BACKEND_POD_NAME -c cvat-backend-app-container -- python manage.py createsuperuser -``` -## FAQ - -### What is kubernetes and how it is working? -See -### What is helm and how it is working? -See -### How to setup Minikube -1. Please follow the official Minikube installation [guide](https://minikube.sigs.k8s.io/docs/start/) -1. ```shell - minikube start --addons registry,registry-aliases - ``` -### How to understand what diff will be inflicted by 'helm upgrade'? -You can use for that -### I want to use my own postgresql/redis with your chart. -Just set `postgresql.enabled` or `redis.enabled` to `false`, as described below. -Then - put your instance params to "external" field -### I want to override some settings in values.yaml. -Just create file `values.override.yaml` and place your changes here, using same structure as in `values.yaml`. -Then reference it in helm update/install command using `-f` flag -### Why you used external charts to provide redis and postgres? -Because they definitely know what they do better then we are, so we are getting more quality and less support +[See for details](https://opencv.github.io/cvat/docs/administration/advanced/k8s_deployment_with_helm/) diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 01ce3252bc91..a1f871532219 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -272,6 +272,11 @@ ingress: service: name: backend-service port: 8080 + - path: /static/admin + pathType: "Prefix" + service: + name: backend-service + port: 8080 - path: /django-rq pathType: "Prefix" service: diff --git a/package.json b/package.json index aea72342b35b..753cb40d6b8a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "eslint-config-airbnb-base": "14.2.1", "eslint-config-airbnb-typescript": "^12.0.0", "eslint-plugin-cypress": "^2.11.2", - "eslint-plugin-header": "^3.1.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^26.5.3", "eslint-plugin-jsx-a11y": "^6.3.1", diff --git a/serverless/pytorch/foolwood/siammask/nuclio/function-gpu.yaml b/serverless/pytorch/foolwood/siammask/nuclio/function-gpu.yaml index 119c3f54ffb6..154c1b783017 100644 --- a/serverless/pytorch/foolwood/siammask/nuclio/function-gpu.yaml +++ b/serverless/pytorch/foolwood/siammask/nuclio/function-gpu.yaml @@ -18,7 +18,7 @@ spec: build: image: cvat.pth.foolwood.siammask - baseImage: nvidia/cuda:11.1-devel-ubuntu20.04 + baseImage: nvidia/cuda:11.1.1-devel-ubuntu20.04 directives: preCopy: diff --git a/serverless/pytorch/saic-vul/fbrs/nuclio/function.yaml b/serverless/pytorch/saic-vul/fbrs/nuclio/function.yaml index fa53090b4498..6b11b2ff9880 100644 --- a/serverless/pytorch/saic-vul/fbrs/nuclio/function.yaml +++ b/serverless/pytorch/saic-vul/fbrs/nuclio/function.yaml @@ -37,9 +37,7 @@ spec: - kind: ENV value: filename=resnet101_dh256_sbd.pth - kind: RUN - value: curl -c ./cookie -s -L "https://drive.google.com/uc?export=download&id=${fileid}" - - kind: RUN - value: curl -Lb ./cookie "https://drive.google.com/uc?export=download&confirm=`awk '/download/ {print $NF}' ./cookie`&id=${fileid}" -o ${filename} + value: curl -L "https://drive.google.com/uc?export=download&confirm=t&id=${fileid}" -o ${filename} - kind: RUN value: apt update && apt install -y libgl1-mesa-glx - kind: RUN diff --git a/serverless/pytorch/saic-vul/hrnet/nuclio/function-gpu.yaml b/serverless/pytorch/saic-vul/hrnet/nuclio/function-gpu.yaml index e3039b7778e8..d8471a3fa4b7 100644 --- a/serverless/pytorch/saic-vul/hrnet/nuclio/function-gpu.yaml +++ b/serverless/pytorch/saic-vul/hrnet/nuclio/function-gpu.yaml @@ -32,8 +32,12 @@ spec: value: apt-get update && apt-get install software-properties-common -y - kind: RUN value: add-apt-repository ppa:deadsnakes/ppa + - kind: RUN + value: apt remove python* -y - kind: RUN value: apt-get update && apt-get install -y --no-install-recommends build-essential git curl libglib2.0-0 software-properties-common python3 python3.6-dev python3-pip python3-tk + - kind: RUN + value: ln -s /usr/bin/pip3 /usr/local/bin/pip && ln -s /usr/bin/python3 /usr/bin/python - kind: RUN value: pip3 install --upgrade pip - kind: WORKDIR diff --git a/serverless/pytorch/shiyinzhang/iog/nuclio/function.yaml b/serverless/pytorch/shiyinzhang/iog/nuclio/function.yaml index 3b979ee6c267..20e10ab195eb 100644 --- a/serverless/pytorch/shiyinzhang/iog/nuclio/function.yaml +++ b/serverless/pytorch/shiyinzhang/iog/nuclio/function.yaml @@ -14,7 +14,7 @@ metadata: spec: description: Interactive Object Segmentation with Inside-Outside Guidance - runtime: 'python:3.6' + runtime: 'python:3.9' handler: main:handler eventTimeout: 30s env: @@ -30,15 +30,19 @@ spec: - kind: WORKDIR value: /opt/nuclio - kind: RUN - value: conda create -y -n iog python=3.6 + value: conda create -y -n iog python=3.9 - kind: SHELL value: '["conda", "run", "-n", "iog", "/bin/bash", "-c"]' - kind: RUN value: conda install -y -c anaconda curl - kind: RUN - value: conda install -y pytorch=0.4 torchvision=0.2 -c pytorch + value: conda install -y pytorch torchvision -c pytorch - kind: RUN - value: conda install -y -c conda-forge pycocotools opencv scipy + value: apt update && apt install -y libgl1-mesa-glx + - kind: RUN + value: conda install -y -c conda-forge pycocotools scipy + - kind: RUN + value: pip install opencv-python - kind: RUN value: git clone https://github.com/shiyinzhang/Inside-Outside-Guidance.git iog - kind: WORKDIR @@ -48,11 +52,7 @@ spec: - kind: ENV value: filename=IOG_PASCAL_SBD.pth - kind: RUN - value: curl -c ./cookie -s -L "https://drive.google.com/uc?export=download&id=${fileid}" - - kind: RUN - value: echo "/download/ {print \$NF}" > confirm_code.awk - - kind: RUN - value: curl -Lb ./cookie "https://drive.google.com/uc?export=download&confirm=`awk -f confirm_code.awk ./cookie`&id=${fileid}" -o ${filename} + value: curl -L "https://drive.google.com/uc?export=download&confirm=t&id=${fileid}" -o ${filename} - kind: WORKDIR value: /opt/nuclio - kind: ENTRYPOINT diff --git a/serverless/pytorch/ultralytics/yolov5/nuclio/function-gpu.yaml b/serverless/pytorch/ultralytics/yolov5/nuclio/function-gpu.yaml new file mode 100644 index 000000000000..99112d135065 --- /dev/null +++ b/serverless/pytorch/ultralytics/yolov5/nuclio/function-gpu.yaml @@ -0,0 +1,127 @@ +metadata: + name: ultralytics-yolov5 + namespace: cvat + annotations: + name: YOLO v5 + type: detector + framework: pytorch + spec: | + [ + { "id": 0, "name": "person" }, + { "id": 1, "name": "bicycle" }, + { "id": 2, "name": "car" }, + { "id": 3, "name": "motorbike" }, + { "id": 4, "name": "aeroplane" }, + { "id": 5, "name": "bus" }, + { "id": 6, "name": "train" }, + { "id": 7, "name": "truck" }, + { "id": 8, "name": "boat" }, + { "id": 9, "name": "traffic light" }, + { "id": 10, "name": "fire hydrant" }, + { "id": 11, "name": "stop sign" }, + { "id": 12, "name": "parking meter" }, + { "id": 13, "name": "bench" }, + { "id": 14, "name": "bird" }, + { "id": 15, "name": "cat" }, + { "id": 16, "name": "dog" }, + { "id": 17, "name": "horse" }, + { "id": 18, "name": "sheep" }, + { "id": 19, "name": "cow" }, + { "id": 20, "name": "elephant" }, + { "id": 21, "name": "bear" }, + { "id": 22, "name": "zebra" }, + { "id": 23, "name": "giraffe" }, + { "id": 24, "name": "backpack" }, + { "id": 25, "name": "umbrella" }, + { "id": 26, "name": "handbag" }, + { "id": 27, "name": "tie" }, + { "id": 28, "name": "suitcase" }, + { "id": 29, "name": "frisbee" }, + { "id": 30, "name": "skis" }, + { "id": 31, "name": "snowboard" }, + { "id": 32, "name": "sports ball" }, + { "id": 33, "name": "kite" }, + { "id": 34, "name": "baseball bat" }, + { "id": 35, "name": "baseball glove" }, + { "id": 36, "name": "skateboard" }, + { "id": 37, "name": "surfboard" }, + { "id": 38, "name": "tennis racket" }, + { "id": 39, "name": "bottle" }, + { "id": 40, "name": "wine glass" }, + { "id": 41, "name": "cup" }, + { "id": 42, "name": "fork" }, + { "id": 43, "name": "knife" }, + { "id": 44, "name": "spoon" }, + { "id": 45, "name": "bowl" }, + { "id": 46, "name": "banana" }, + { "id": 47, "name": "apple" }, + { "id": 48, "name": "sandwich" }, + { "id": 49, "name": "orange" }, + { "id": 50, "name": "broccoli" }, + { "id": 51, "name": "carrot" }, + { "id": 52, "name": "hot dog" }, + { "id": 53, "name": "pizza" }, + { "id": 54, "name": "donut" }, + { "id": 55, "name": "cake" }, + { "id": 56, "name": "chair" }, + { "id": 57, "name": "sofa" }, + { "id": 58, "name": "pottedplant" }, + { "id": 59, "name": "bed" }, + { "id": 60, "name": "diningtable" }, + { "id": 61, "name": "toilet" }, + { "id": 62, "name": "tvmonitor" }, + { "id": 63, "name": "laptop" }, + { "id": 64, "name": "mouse" }, + { "id": 65, "name": "remote" }, + { "id": 66, "name": "keyboard" }, + { "id": 67, "name": "cell phone" }, + { "id": 68, "name": "microwave" }, + { "id": 69, "name": "oven" }, + { "id": 70, "name": "toaster" }, + { "id": 71, "name": "sink" }, + { "id": 72, "name": "refrigerator" }, + { "id": 73, "name": "book" }, + { "id": 74, "name": "clock" }, + { "id": 75, "name": "vase" }, + { "id": 76, "name": "scissors" }, + { "id": 77, "name": "teddy bear" }, + { "id": 78, "name": "hair drier" }, + { "id": 79, "name": "toothbrush" } + ] + +spec: + description: YOLO v5 via pytorch hub + runtime: 'python:3.6' + handler: main:handler + eventTimeout: 30s + build: + image: cvat/ultralytics-yolov5 + baseImage: ultralytics/yolov5:latest + + directives: + preCopy: + - kind: USER + value: root + - kind: RUN + value: apt update && apt install --no-install-recommends -y libglib2.0-0 + - kind: WORKDIR + value: /opt/nuclio + + triggers: + myHttpTrigger: + maxWorkers: 1 + kind: 'http' + workerAvailabilityTimeoutMilliseconds: 10000 + attributes: + maxRequestBodySize: 33554432 # 32MB + + resources: + limits: + nvidia.com/gpu: 1 + + platform: + attributes: + restartPolicy: + name: always + maximumRetryCount: 3 + mountMode: volume diff --git a/site/build_docs.py b/site/build_docs.py old mode 100644 new mode 100755 index c2378b8f1334..7a41699126d2 --- a/site/build_docs.py +++ b/site/build_docs.py @@ -1,87 +1,111 @@ +#!/usr/bin/env python3 + # Copyright (C) 2021-2022 Intel Corporation +# Copyright (C) 2022 CVAT.ai Corporation # # SPDX-License-Identifier: MIT -import os import shutil import subprocess +import tarfile +import tempfile +from pathlib import Path -from packaging import version import git import toml +from packaging import version + +# the initial version for the documentation site +MINIMUM_VERSION = version.Version("1.5.0") -MINIMUM_VERSION='1.5.0' def prepare_tags(repo): tags = {} for tag in repo.tags: tag_version = version.parse(tag.name) - if tag_version >= version.Version(MINIMUM_VERSION) and not tag_version.is_prerelease: + if tag_version >= MINIMUM_VERSION and not tag_version.is_prerelease: release_version = (tag_version.major, tag_version.minor) - if not release_version in tags or tag_version > version.parse(tags[release_version].name): + if release_version not in tags or tag_version > version.parse( + tags[release_version].name + ): tags[release_version] = tag return tags.values() -def generate_versioning_config(filename, versions, url_prefix=''): + +def generate_versioning_config(filename, versions, url_prefix=""): def write_version_item(file_object, version, url): - file_object.write('[[params.versions]]\n') + file_object.write("[[params.versions]]\n") file_object.write('version = "{}"\n'.format(version)) file_object.write('url = "{}"\n\n'.format(url)) - with open(filename, 'w') as f: - write_version_item(f, 'Latest version', '{}/'.format(url_prefix)) + with open(filename, "w") as f: + write_version_item(f, "Latest version", "{}/".format(url_prefix)) for v in versions: - write_version_item(f, v, '{}/{}'.format(url_prefix, v)) + write_version_item(f, v, "{}/{}".format(url_prefix, v)) + + +def git_checkout(tagname, repo, temp_dir): + subdirs = ["site/content/en/docs", "site/content/en/images"] + + for subdir in subdirs: + shutil.rmtree(temp_dir / subdir) + + with tempfile.TemporaryFile() as archive: + # `git checkout` doesn't work for this, as it modifies the index. + # `git restore` would work, but it's only available since Git 2.23. + repo.git.archive(tagname, "--", subdir, output_stream=archive) + archive.seek(0) + with tarfile.open(fileobj=archive) as tar: + tar.extractall(temp_dir) -def git_checkout(tagname, cwd): - docs_dir = os.path.join(cwd, 'site', 'content', 'en', 'docs') - shutil.rmtree(docs_dir) - repo.git.checkout(tagname, '--', 'site/content/en/docs') - images_dir = os.path.join(cwd, 'site', 'content', 'en', 'images') - shutil.rmtree(images_dir) - repo.git.checkout(tagname, '--', 'site/content/en/images') def change_version_menu_toml(filename, version): data = toml.load(filename) - data['params']['version_menu'] = version + data["params"]["version_menu"] = version - with open(filename,'w') as f: + with open(filename, "w") as f: toml.dump(data, f) + def generate_docs(repo, output_dir, tags): - def run_hugo(content_loc, destination_dir): - subprocess.run([ # nosec - 'hugo', - '--destination', - destination_dir, - '--config', - 'config.toml,versioning.toml', - ], - cwd=content_loc, - ) - - cwd = repo.working_tree_dir - content_loc = os.path.join(cwd, 'site') - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - generate_versioning_config(os.path.join(cwd, 'site', 'versioning.toml'), (t.name for t in tags)) - change_version_menu_toml(os.path.join(cwd, 'site', 'versioning.toml'), 'Latest version') - run_hugo(content_loc, output_dir) - - generate_versioning_config(os.path.join(cwd, 'site', 'versioning.toml'), (t.name for t in tags), '/..') - for tag in tags: - git_checkout(tag.name, cwd) - destination_dir = os.path.join(output_dir, tag.name) - change_version_menu_toml(os.path.join(cwd, 'site', 'versioning.toml'), tag.name) - os.makedirs(destination_dir) - run_hugo(content_loc, destination_dir) + repo_root = Path(repo.working_tree_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + content_loc = Path(temp_dir, "site") + shutil.copytree(repo_root / "site", content_loc, symlinks=True) + + def run_hugo(destination_dir): + subprocess.run( # nosec + [ + "hugo", + "--destination", + str(destination_dir), + "--config", + "config.toml,versioning.toml", + ], + cwd=content_loc, + check=True, + ) + + versioning_toml_path = content_loc / "versioning.toml" + + # Handle the develop version + generate_versioning_config(versioning_toml_path, (t.name for t in tags)) + change_version_menu_toml(versioning_toml_path, "develop") + run_hugo(output_dir) + + generate_versioning_config(versioning_toml_path, (t.name for t in tags), "/..") + for tag in tags: + git_checkout(tag.name, repo, Path(temp_dir)) + change_version_menu_toml(versioning_toml_path, tag.name) + run_hugo(output_dir / tag.name) + if __name__ == "__main__": - repo_root = os.getcwd() - repo = git.Repo(repo_root) - output_dir = os.path.join(repo_root, 'public') + repo_root = Path(__file__).resolve().parents[1] + output_dir = repo_root / "public" - tags = prepare_tags(repo) - generate_docs(repo, output_dir, tags) + with git.Repo(repo_root) as repo: + tags = prepare_tags(repo) + generate_docs(repo, output_dir, tags) diff --git a/site/config.toml b/site/config.toml index 7d2f6a6563a5..a343c86bdfaf 100644 --- a/site/config.toml +++ b/site/config.toml @@ -8,7 +8,7 @@ enableRobotsTXT = true theme = ["docsy"] # Will give values to .Lastmod etc. -enableGitInfo = true +enableGitInfo = false # Language settings contentDir = "content/en" diff --git a/site/content/en/about/_index.html b/site/content/en/about/_index.html index cc009bf3dc64..0ca434930b6b 100644 --- a/site/content/en/about/_index.html +++ b/site/content/en/about/_index.html @@ -12,7 +12,10 @@

    About Us

    -

    CVAT was designed to provide users with a set of convenient instruments for annotating digital images and videos.
    CVAT supports supervised machine learning tasks pertaining to object detection, image classification, image segmentation and 3D data annotation. It allows users to annotate images with four types of shapes: boxes, polygons (both generally and for segmentation tasks), polylines (e.g., for annotation of markings on roads),
    and points (e.g., for annotation of face landmarks or pose estimation).

    +

    CVAT was designed to provide users with a set of convenient instruments for annotating digital images and videos.
    + CVAT supports supervised machine learning tasks pertaining to object detection, image classification, image segmentation + and 3D data annotation. It allows users to annotate images with multiple tools + (boxes, polygons, cuboids, circles, skeletons, etc).

    {{< /blocks/cover >}} @@ -21,7 +24,11 @@

    About Us

    {{< blocks/section height="auto" color="info" >}}
    -

    Data scientists need annotated data (and lots of it) to train the deep neural networks (DNNs) at the core of AI workflows. Obtaining annotated data or annotating data yourself is a challenging and time-consuming process.
    For example, it took about 3,100 total hours for members of Intel’s own data annotation team to annotate more than 769,000 objects for just one of our algorithms. To help solve this challenge, Intel is conducting research to find better methods of data annotation and deliver tools that help developers do the same.

    +

    Data scientists need annotated data (and lots of it) to train the deep neural networks (DNNs) at the core of AI workflows. + Obtaining annotated data or annotating data yourself is a challenging and time-consuming process.
    For example, it took + about 3,100 total hours for members of Intel’s own data annotation team to annotate more than 769,000 objects for just one + of our algorithms. To help solve this challenge, CVAT.ai is conducting research to find better methods of data annotation and + deliver tools that help developers do the same.

    @@ -66,72 +73,26 @@

    202X {{< /blocks/section >}} - -{{< blocks/section height="auto" color="info" >}} - -

    Core Team

    -
    - - -{{< /blocks/section >}} - - {{< blocks/section height="auto" color="docs" >}}

    Contact Us:

    - -
    +
    -

    Russia, Nizhny Novgorod, Turgeneva street 30 (campus TGV)

    +

    365 Agiou Andreou, Office 201, 3035 Limassol, Cyprus

    - Feedback from users helps Intel determine future direction for CVAT’s development. We hope to improve the tool’s user experience, feature set, stability, automation features and ability to be integrated with other services and encourage members of the community to take an active part in CVAT’s development. + Feedback from users helps CVAT team to determine future direction for CVAT’s development. We hope to improve the tool’s + user experience, feature set, stability, automation features and ability to be integrated with other services and encourage + members of the community to take an active part in CVAT’s development.

    diff --git a/site/content/en/docs/administration/_index.md b/site/content/en/docs/administration/_index.md index c3e09d424b58..97e2cd729ea6 100644 --- a/site/content/en/docs/administration/_index.md +++ b/site/content/en/docs/administration/_index.md @@ -1,6 +1,6 @@ --- title: 'Administration' linkTitle: 'Administration' -weight: 3 +weight: 4 description: 'This section contains documents for system administrators.' --- diff --git a/site/content/en/docs/administration/advanced/analytics.md b/site/content/en/docs/administration/advanced/analytics.md index 576a63f51ff4..92a8dc2e28de 100644 --- a/site/content/en/docs/administration/advanced/analytics.md +++ b/site/content/en/docs/administration/advanced/analytics.md @@ -4,7 +4,7 @@ title: 'Installation Analytics' linkTitle: 'Installation Analytics' -weight: 2 +weight: 20 description: 'Instructions for deployment and customization of Analytics. This section on [GitHub](https://github.com/cvat-ai/cvat/tree/develop/components/analytics).' --- diff --git a/site/content/en/docs/administration/advanced/backup_guide.md b/site/content/en/docs/administration/advanced/backup_guide.md index af5230ed765b..3b5cad6f005a 100644 --- a/site/content/en/docs/administration/advanced/backup_guide.md +++ b/site/content/en/docs/administration/advanced/backup_guide.md @@ -1,7 +1,7 @@ --- title: 'Backup guide' linkTitle: 'Backup guide' -weight: 11 +weight: 50 description: 'Instructions on how to backup CVAT data with Docker.' --- diff --git a/site/content/en/docs/administration/advanced/iam_system_roles.md b/site/content/en/docs/administration/advanced/iam_system_roles.md index 11cefdb7873e..17cd5d79a5fc 100644 --- a/site/content/en/docs/administration/advanced/iam_system_roles.md +++ b/site/content/en/docs/administration/advanced/iam_system_roles.md @@ -1,9 +1,10 @@ --- title: 'IAM: system roles' linkTitle: 'system roles' -weight: 22 +weight: 70 --- ## System roles +TBD diff --git a/site/content/en/docs/administration/advanced/installation_automatic_annotation.md b/site/content/en/docs/administration/advanced/installation_automatic_annotation.md index 38e7c94a56b6..8e0bed35194c 100644 --- a/site/content/en/docs/administration/advanced/installation_automatic_annotation.md +++ b/site/content/en/docs/administration/advanced/installation_automatic_annotation.md @@ -4,7 +4,7 @@ title: 'Semi-automatic and Automatic Annotation' linkTitle: 'Installation Auto Annotation' -weight: 5 +weight: 10 description: 'Information about the installation of components needed for semi-automatic and automatic annotation.' --- diff --git a/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md b/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md new file mode 100644 index 000000000000..5b7773118ebb --- /dev/null +++ b/site/content/en/docs/administration/advanced/k8s_deployment_with_helm.md @@ -0,0 +1,240 @@ +--- + +title: 'CVAT deployment on Kubernetes with Helm' +linkTitle: 'CVAT deployment on Kubernetes with Helm' +weight: 1 +description: 'Instructions for deploying CVAT on a Kubernetes cluster.' + +--- + + + + +- [Prerequisites](#prerequisites) + - [Installing dependencies](#installing-dependencies) + - [Optional steps](#optional-steps) +- [Configuration](#configuration) + - [Postgresql password?](#postgresql-password) + - [(Optional) Enable Auto annotation feature](#optional-enable-auto-annotation-feature) + - [(Optional) Enable Analytics](#optional-enable-analytics) +- [Deployment](#deployment) + - [With overrides:](#with-overrides) + - [Without overrides:](#without-overrides) +- [Post-deployment configuration](#post-deployment-configuration) + - [How to create superuser?](#how-to-create-superuser) +- [FAQ](#faq) + - [What is kubernetes and how it is working?](#what-is-kubernetes-and-how-it-is-working) + - [What is helm and how it is working?](#what-is-helm-and-how-it-is-working) + - [How to setup Minikube](#how-to-setup-minikube) + - [How to understand what diff will be inflicted by 'helm upgrade'?](#how-to-understand-what-diff-will-be-inflicted-by-helm-upgrade) + - [I want to use my own postgresql/redis with your chart.](#i-want-to-use-my-own-postgresqlredis-with-your-chart) + - [I want to override some settings in values.yaml.](#i-want-to-override-some-settings-in-valuesyaml) + - [Why you used external charts to provide redis and postgres?](#why-you-used-external-charts-to-provide-redis-and-postgres) + +## Prerequisites +1. Installed and configured [kubernetes](https://kubernetes.io/) cluster. If you do not already have a cluster, + you can create one by using [Minikube](https://github.com/kubernetes/minikube/). [How to setup Minikube](#how-to-setup-minikube). +1. Installed [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) +1. Installed [Helm](https://helm.sh/). +1. Installed [dependencies](#installing-dependencies) + +### Installing dependencies +To install and/or update run: +```shell +helm dependency update +``` + +### Optional steps +1. Ingress configuration for the Traefik ingress controller is enabled by default. + + Note for Minikube use: + - because the Traefik creates its main service with `Loadbalanser` type, + which involve the assignment of externalIP by Cloud, what never happens on Minikube, + you need to explicitly set the externalIP address for the traefic service. + Add the following to `values.override.yaml` file: + ```yaml + traefik: + service: + externalIPs: + - "your minikube IP (can be obtained with `minicube ip` command)" + ``` + - Also ensure that your CVAT ingress appears on your hosts file (/etc/hosts). + You can do this by running this command: + `cvat.local` is default domainname, you can override it via `values.override.yaml`. + ```shell + echo "$(minikube ip) cvat.local" | sudo tee -a /etc/hosts + ``` + +## Configuration +1. Create `values.override.yaml` file inside `helm-chart` directory. +1. Fill `values.override.yaml` with new parameters for chart. +1. Override [postgresql password](#postgresql-password) +1. Create a rules.tar.gz archive containing all OPA rules inside this `helm-chart` directory. + ```shell + find ../cvat/apps/iam/rules -name "*.rego" -and ! -name '*test*' -exec basename {} \; | tar -czf rules.tar.gz -C ../cvat/apps/iam/rules/ -T - + ``` + +### Postgresql password? +Put below into your `values.override.yaml` +```yaml +postgresql: + secret: + password: + postgres_password: + replication_password: +``` +Or create your own secret and use it with: +```yaml +postgresql: + global: + postgresql: + existingSecret: +``` + +### (Optional) Enable Auto annotation feature + +Before starting, ensure that the following prerequisites are met: +- The Nuclio [CLI (nuctl)](https://nuclio.io/docs/latest/reference/nuctl/nuctl/) is installed. + To install the CLI, simply [download](https://github.com/nuclio/nuclio/releases) + the appropriate CLI version to your installation machine. + +1. Set `nuclio.enabled: true` in your `values.override.yaml` +1. Run `helm dependency update` in `helm-chart` directory +1. Because Nuclio functions are images that need to be pushed and pulled to/from the registry, + you need to configure credentials to pull from your preferable registry with the following settings: + Options: + - `values.override.yaml` file: + ```yaml + registry: + loginUrl: someurl + credentials: + username: someuser + password: somepass + ``` + - Or you can create a secret with credentials as described in the [guide](https://nuclio.io/docs/latest/setup/k8s/running-in-production-k8s/#the-preferred-deployment-method) + and set `registry.secretName=your-registry-credentials-secret-name` in the `values.override.yaml` file. + + - In the case of using Minikube, you can run a local unsecured registry with minikube add-ons: + ```shell + minikube addons enable registry + minikube addons enable registry-aliases + ``` + Before Docker container images can be pushed to your newly created unsecure registry, + you need to add its address (`$(minikube ip):5000`) to the list of unsecure registries to + instruct Docker to accept working against it: + follow the instructions in the [Docker documentation](https://docs.docker.com/registry/insecure/#deploy-a-plain-http-registry) + + You might also need to log into your registry account (docker login) + on the installation machine before running the deployment command. + +1. Create cvat project: + ```shell + nuctl --namespace create project cvat + ``` +1. Finaly deploy the fuction, i.e.: + - using minikube registry: + ```shell + nuctl deploy --project-name cvat --path serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio --registry $(minikube ip):5000 --run-registry registry.minikube + ``` + - using Docker hub: + ```shell + nuctl deploy --project-name cvat --path serverless/tensorflow/faster_rcnn_inception_v2_coco/nuclio --registry docker.io/your_username + ``` + +### (Optional) Enable Analytics + +1. Set `analytics.enabled: true` in your `values.override.yaml` +1. Run `helm dependency update` in `helm-chart` directory +1. Since custom images are required here, you will need to create them yourself + and push them to your preferred docker registry. + You might also need to log into your registry account (docker login) + on the installation machine before running the push command. + How to set up local registry when using Minikube see [previous section](#how_to_enable_auto_annotation_feature) + + - Let's build custom elasticsearch, logstash and kibana images with the following command + ```shell + docker-compose -f docker-compose.yml -f components/analytics/docker-compose.analytics.yml build + ``` + + - Tag images: + ```shell + docker tag cvat_kibana:latest /cvat_kibana:latest + docker tag cvat_elasticsearch:latest /cvat_elasticsearch:latest + docker tag cvat_logstash:latest /cvat_logstash:latest + ``` + + - Push to registry + ```shell + docker push /cvat_kibana:latest + docker push /cvat_elasticsearch:latest + docker push /cvat_logstash:latest + ``` + + - Add corresponding settings into `values.override.yaml`, i.e. for minikube registry: + ```yaml + logstash: + image: "registry.minikube/cvat_logstash" + imageTag: "latest" + + elasticsearch: + image: "registry.minikube/cvat_elasticsearch" + imageTag: "latest" + + kibana: + image: "registry.minikube/cvat_kibana" + imageTag: "latest" + ``` + + - Deploy + ```shell + helm upgrade --namespace --install ./helm-chart -f ./helm-chart/values.yaml -f values.override.yaml + ``` + +## Deployment +Make sure you are using correct kubernetes context. You can check it with `kubectl config current-context`. + +> **Warning:** The k8s service name of Open Policy Agent is fixed to opa by default. +> This is done to be compatible with CVAT 2.0 but limits this helm chart to a single release per namespace. +> The OPA url currently can´t be set as an environment variable. +> As soon as this is possible you can set cvat.opa.composeCompatibleServiceName +> to false in your value.override.yaml and configure the opa url as additional env. + +Execute following command from repo root directory +### With overrides: +```helm upgrade -n -i --create-namespace ./helm-chart -f ./helm-chart/values.yaml -f ./helm-chart/values.override.yaml``` + +### Without overrides: +```helm upgrade -n -i --create-namespace ./helm-chart -f ./helm-chart/values.yaml``` + +## Post-deployment configuration + +1. Create [super user](#how-to-create-superuser) + +### How to create superuser? +```sh +HELM_RELEASE_NAMESPACE="" &&\ +HELM_RELEASE_NAME="" &&\ +BACKEND_POD_NAME=$(kubectl get pod --namespace $HELM_RELEASE_NAMESPACE -l tier=backend,app.kubernetes.io/instance=$HELM_RELEASE_NAME -o jsonpath='{.items[0].metadata.name}') &&\ +kubectl exec -it --namespace $HELM_RELEASE_NAMESPACE $BACKEND_POD_NAME -c cvat-backend-app-container -- python manage.py createsuperuser +``` +## FAQ + +### What is kubernetes and how it is working? +See +### What is helm and how it is working? +See +### How to setup Minikube +1. Please follow the official Minikube installation [guide](https://minikube.sigs.k8s.io/docs/start/) +1. ```shell + minikube start --addons registry,registry-aliases + ``` +### How to understand what diff will be inflicted by 'helm upgrade'? +You can use for that +### I want to use my own postgresql/redis with your chart. +Just set `postgresql.enabled` or `redis.enabled` to `false`, as described below. +Then - put your instance params to "external" field +### I want to override some settings in values.yaml. +Just create file `values.override.yaml` and place your changes here, using same structure as in `values.yaml`. +Then reference it in helm update/install command using `-f` flag +### Why you used external charts to provide redis and postgres? +Because they definitely know what they do better then we are, so we are getting more quality and less support diff --git a/site/content/en/docs/administration/advanced/ldap.md b/site/content/en/docs/administration/advanced/ldap.md index 0e8024e1c301..783f1a6ffdde 100644 --- a/site/content/en/docs/administration/advanced/ldap.md +++ b/site/content/en/docs/administration/advanced/ldap.md @@ -4,7 +4,7 @@ title: 'LDAP Backed Authentication' linkTitle: 'LDAP Login' -weight: 5 +weight: 40 description: 'Allow users to login with credentials from a central source' --- diff --git a/site/content/en/docs/administration/advanced/mounting_cloud_storages.md b/site/content/en/docs/administration/advanced/mounting_cloud_storages.md index 7f97463b541e..2ed712e1f79c 100644 --- a/site/content/en/docs/administration/advanced/mounting_cloud_storages.md +++ b/site/content/en/docs/administration/advanced/mounting_cloud_storages.md @@ -1,7 +1,7 @@ --- title: 'Mounting cloud storage' linkTitle: 'Mounting cloud storage' -weight: 10 +weight: 30 description: 'Instructions on how to mount AWS S3 bucket, Microsoft Azure container or Google Drive as a filesystem.' --- diff --git a/site/content/en/docs/administration/advanced/upgrade_guide.md b/site/content/en/docs/administration/advanced/upgrade_guide.md index ee75c75477bf..ba7e1746fbd7 100644 --- a/site/content/en/docs/administration/advanced/upgrade_guide.md +++ b/site/content/en/docs/administration/advanced/upgrade_guide.md @@ -1,7 +1,7 @@ --- title: 'Upgrade guide' linkTitle: 'Upgrade guide' -weight: 11 +weight: 60 description: 'Instructions for upgrading CVAT deployed with docker compose' --- diff --git a/site/content/en/docs/administration/advanced/webhooks.md b/site/content/en/docs/administration/advanced/webhooks.md new file mode 100644 index 000000000000..b6615ed7aff0 --- /dev/null +++ b/site/content/en/docs/administration/advanced/webhooks.md @@ -0,0 +1,366 @@ +--- +title: 'Webhooks' +linkTitle: 'Webhooks' +description: 'Instructions for working with CVAT Webhooks' +weight: 80 +--- + +## Create Webhook + +In CVAT you can create webhook for project or for organization. +For creation, you can use our user interface or direct API calls. + +In order to create webhook via an API call, see the [swagger documentation](https://app.cvat.ai/api/docs). +And also see examples of creating webhooks in our [REST API tests](https://github.com/opencv/cvat/blob/develop/tests/python/rest_api/test_webhooks.py). + +### Create Webhook for project + +To create webhook for CVAT project, follow the steps: + +`Project -> Actions -> Setup Webhooks` + +![](/images/create_project_webhook.gif) + +### Create Webhook for organization + +To create webhook for CVAT organization, follow the steps: + +`Organization -> Settings -> Actions -> Setup Webhooks` + +![](/images/create_organization_webhook.gif) + +## List of available events + +| Resource | Create | Update | Delete | +| :---: | :----: | :----: | :----: | +| Organization | | ✅ | | +| Membership | | ✅ | ✅ | +| Invitation | ✅ | | ✅ | +| Project | ✅ | ✅ | ✅ | +| Task | ✅ | ✅ | ✅ | +| Job | | ✅ | | +| Issue | ✅ | ✅ | ✅ | +| Comment | ✅ | ✅ | ✅ | + +## Payloads + +### Create event + +Webhook payload object for `create:` events: + +| Key | Type | Description | +| :---: | :----: | :---- | +| `event` | `string` | Name of event that triggered webhook with pattern `create:` | +| `` | `object` | Full information about created resource. See the swagger docs for each separate resource | +| `webhook_id` | `integer` | Identifier of webhook that sent payload | +| `sender` | `object` | Information about user that triggered webhook | + +Here is example of payload for `create:task` event: +{{< scroll-code lang="json" >}} +{ + "event": "create:task", + "task": { + "url": "", + "id": 15, + "name": "task", + "project_id": 7, + "mode": "", + "owner": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + }, + "assignee": null, + "bug_tracker": "", + "created_date": "2022-10-04T08:05:50.419259Z", + "updated_date": "2022-10-04T08:05:50.422917Z", + "overlap": null, + "segment_size": 0, + "status": "annotation", + "labels": \[ + { + "id": 28, + "name": "label_0", + "color": "#bde94a", + "attributes": [], + "type": "any", + "sublabels": [], + "has_parent": false + } + \], + "segments": [], + "dimension": "2d", + "subset": "", + "organization": null, + "target_storage": { + "id": 14, + "location": "local", + "cloud_storage_id": null + }, + "source_storage": { + "id": 13, + "location": "local", + "cloud_storage_id": null + } + }, + "webhook_id": 7, + "sender": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + } +} +{{< /scroll-code >}} + + +### Update event + +Webhook payload object for `update:` events: + +| Key | Type | Description | +| :---: | :----: | :---- | +| `event` | `string` | Name of event that triggered webhook with pattern `update:` | +| `` | `object` | Full information about updated resource. See the swagger docs for each separate resource | +| `before_update` | `object` | Keys of `` that was updated with theirs old values | +| `webhook_id` | `integer` | Identifier of webhook that sent payload | +| `sender` | `object` | Information about user that triggered webhook | + +{{< scroll-code lang="json" >}} +{ + "event": "update:task", + "task": { + "url": "", + "id": 15, + "name": "new task name", + "project_id": 7, + "mode": "annotation", + "owner": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + }, + "assignee": null, + "bug_tracker": "", + "created_date": "2022-10-04T08:05:50.419259Z", + "updated_date": "2022-10-04T11:04:51.451681Z", + "overlap": 0, + "segment_size": 1, + "status": "annotation", + "labels": \[ + { + "id": 28, + "name": "label_0", + "color": "#bde94a", + "attributes": [], + "type": "any", + "sublabels": [], + "has_parent": false + } + \], + "segments": \[ + { + "start_frame": 0, + "stop_frame": 0, + "jobs": \[ + { + "url": "", + "id": 19, + "assignee": null, + "status": "annotation", + "stage": "annotation", + "state": "new" + } + \] + } + \], + "data_chunk_size": 14, + "data_compressed_chunk_type": "imageset", + "data_original_chunk_type": "imageset", + "size": 1, + "image_quality": 70, + "data": 14, + "dimension": "2d", + "subset": "", + "organization": null, + "target_storage": { + "id": 14, + "location": "local", + "cloud_storage_id": null + }, + "source_storage": { + "id": 13, + "location": "local", + "cloud_storage_id": null + } + }, + "before_update": { + "name": "task" + }, + "webhook_id": 7, + "sender": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + } +} +{{< /scroll-code >}} +### Delete event + +Webhook payload object for `delete:` events: + +| Key | Type | Description | +| :---: | :----: | :---- | +| `event` | `string` | Name of event that triggered webhook with pattern `delete:` | +| `` | `object` | Full information about deleted resource. See the swagger docs for each separate resource | +| `webhook_id` | `integer` | Identifier of webhook that sent payload | +| `sender` | `object` | Information about user that triggered webhook | + +Here is example of payload for `delete:task` event: +{{< scroll-code lang="json" >}} +{ + "event": "delete:task", + "task": { + "url": "", + "id": 15, + "name": "task", + "project_id": 7, + "mode": "", + "owner": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + }, + "assignee": null, + "bug_tracker": "", + "created_date": "2022-10-04T08:05:50.419259Z", + "updated_date": "2022-10-04T08:05:50.422917Z", + "overlap": null, + "segment_size": 0, + "status": "annotation", + "labels": \[ + { + "id": 28, + "name": "label_0", + "color": "#bde94a", + "attributes": [], + "type": "any", + "sublabels": [], + "has_parent": false + } + \], + "segments": [], + "dimension": "2d", + "subset": "", + "organization": null, + "target_storage": { + "id": 14, + "location": "local", + "cloud_storage_id": null + }, + "source_storage": { + "id": 13, + "location": "local", + "cloud_storage_id": null + } + }, + "webhook_id": 7, + "sender": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + } +} +{{< /scroll-code >}} + +## Webhook secret + +To be ensure that webhooks come from CVAT you can specify `secret` when creating a webhook. + +If you specified `secret` value for webhook, then CVAT will sent webhook with `X-Signature-256` in +request header. + +CVAT encode request body for webhook using SHA256 hash function and put the result into the header. + +Webhook receiver can check that request came from CVAT by comparison received value of `X-Signature-256` with expected. + +Example of header value for empty request body and `secret = mykey`: + +``` +X-Signature-256: e1b24265bf2e0b20c81837993b4f1415f7b68c503114d100a40601eca6a2745f +``` + +## Ping Webhook + +To check that webhook configured well and CVAT can connect with target URL you can use `ping` webhook. + +After pressing `Ping` bottom on UI (or sending `POST /webhooks/{id}/ping` request) CVAT will sent webhook +to the target url with general information about webhook. + +Ping webhook payload: + +| Key | Type | Description | +| :---: | :----: | :---- | +| `event` | `string` | Value always equals `ping` | +| `webhook` | `object` | Full information about webhook. See the full description of webhook`s fields in swagger docs | +| `sender` | `object` | Information about user that called `ping` webhook | + +{{< scroll-code lang="json" >}} +{ + "event": "ping", + "webhook": { + "id": 7, + "url": "", + "target_url": "", + "description": "", + "type": "project", + "content_type": "application/json", + "is_active": true, + "enable_ssl": true, + "created_date": "2022-10-04T08:05:23.007381Z", + "updated_date": "2022-10-04T08:05:23.007395Z", + "owner": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + }, + "project": 7, + "organization": null, + "events": \[ + "create:comment", + "create:issue", + "create:task", + "delete:comment", + "delete:issue", + "delete:task", + "update:comment", + "update:issue", + "update:job", + "update:project", + "update:task" + \], + "last_status": 200, + "last_delivery_date": "2022-10-04T11:04:52.538638Z" + }, + "sender": { + "url": "", + "id": 1, + "username": "admin1", + "first_name": "Admin", + "last_name": "First" + } +} +{{< /scroll-code >}} diff --git a/site/content/en/docs/administration/basics/installation.md b/site/content/en/docs/administration/basics/installation.md index 8f4199d08087..087dc1c927c0 100644 --- a/site/content/en/docs/administration/basics/installation.md +++ b/site/content/en/docs/administration/basics/installation.md @@ -472,7 +472,7 @@ to enable email verification (ACCOUNT_EMAIL_VERIFICATION = 'mandatory'). Access is denied until the user's email address is verified. ```python -ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_AUTHENTICATION_METHOD = 'username_email' ACCOUNT_CONFIRM_EMAIL_ON_GET = True ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_VERIFICATION = 'mandatory' diff --git a/site/content/en/docs/api_sdk/_index.md b/site/content/en/docs/api_sdk/_index.md new file mode 100644 index 000000000000..078f4f13b53a --- /dev/null +++ b/site/content/en/docs/api_sdk/_index.md @@ -0,0 +1,26 @@ +--- +title: "API & SDK" +linkTitle: "API & SDK" +weight: 5 +description: 'How to interact with CVAT' +--- + +## Overview + +In the modern world, it is often necessary to integrate different tools to work together. +CVAT provides the following integration layers: + +- Server REST API + Swagger schema +- Python client library (SDK) + - REST API client + - High-level wrappers +- Command-line tool (CLI) + +In this section, you can find documentation about each separate layer. + +## Component compatibility + +Currently, the only supported configuration is when the server API major and minor versions +are the same as SDK and CLI major and minor versions, e.g. server v2.1.* is supported by +SDK and CLI v2.1.*. Different versions may have incompatibilities, which lead to some functions +in SDK or CLI may not work properly. diff --git a/site/content/en/docs/contributing/rest-api-design.md b/site/content/en/docs/api_sdk/api/_index.md similarity index 69% rename from site/content/en/docs/contributing/rest-api-design.md rename to site/content/en/docs/api_sdk/api/_index.md index 4a1eb81b950b..1f2b2fb6d04c 100644 --- a/site/content/en/docs/contributing/rest-api-design.md +++ b/site/content/en/docs/api_sdk/api/_index.md @@ -1,13 +1,37 @@ --- -title: 'REST API design principles' -linkTitle: 'REST API design principles' -weight: 100 -description: 'Information on using the REST API scheme and principles of its design.' +title: 'Server API' +linkTitle: 'API' +weight: 2 +description: '' --- -## REST API scheme +## Overview -Common scheme for our REST API is ` [namespace] `. +CVAT server provides HTTP REST API for interaction. Each client application - be it +a command line tool, browser or a script - all interact with CVAT via HTTP requests and +responses: + +![CVAT server interaction image](/images/server_api_interaction.svg) + +## API schema + +You can obtain schema for your server at `/api/docs`. For example, +the official CVAT.ai application has API documentation [here](https://app.cvat.ai/api/docs/). + +## Examples + +Here you can see how a task is created in CVAT: + +![Task creation example](/images/server_api_create_task_example.png) + +1. At first, we have to login +1. Then we create a task from its configuration +1. Then we send task data (images, videos etc.) +1. We wait for data processing and finish + +## Design principles + +Common pattern for our REST API is ` [namespace] `. - `VERB` can be `POST`, `GET`, `PATCH`, `PUT`, `DELETE`. - `namespace` should scope some specific functionality like `auth`, `lambda`. @@ -18,7 +42,7 @@ Common scheme for our REST API is ` [namespace] `. without `objects` endpoint like `annotations`, `data`, `data/meta`. Note: action should not duplicate other endpoints without a reason. -## Design principles +When you're developing new endpoints, follow these guidelines: - Use nouns instead of verbs in endpoint paths. For example, `POST /api/tasks` instead of `POST /api/tasks/create`. diff --git a/site/content/en/docs/manual/advanced/cli.md b/site/content/en/docs/api_sdk/cli/_index.md similarity index 92% rename from site/content/en/docs/manual/advanced/cli.md rename to site/content/en/docs/api_sdk/cli/_index.md index 047f7f61ad61..03da4a9e7672 100644 --- a/site/content/en/docs/manual/advanced/cli.md +++ b/site/content/en/docs/api_sdk/cli/_index.md @@ -1,11 +1,11 @@ --- title: 'Command line interface (CLI)' linkTitle: 'CLI' -weight: 29 -description: 'Guide to working with CVAT tasks in the command line interface. This section on [GitHub](https://github.com/cvat-ai/cvat/tree/develop/cvat-cli).' +weight: 4 +description: '' --- -## Description +## Overview A simple command line interface for working with CVAT tasks. At the moment it implements a basic feature set but may serve as the starting point for a more @@ -22,21 +22,24 @@ Overview of functionality: - Export and download a whole task - Import a task -## Usage +## Installation -To access the CLI, you need to have python in environment, -as well as a clone of the CVAT repository and the necessary modules: +To install an [official release of CVAT CLI](https://pypi.org/project/cvat-cli/), use this command: ```bash pip install cvat-cli ``` +We support Python versions 3.7 - 3.9. + +## Usage + You can get help with `cvat-cli --help`. ``` -usage: cvat-cli [-h] [--auth USER:[PASS]] [--server-host SERVER_HOST] - [--server-port SERVER_PORT] [--debug] - {create,delete,ls,frames,dump,upload,export,import} ... +usage: cvat-cli [-h] [--auth USER:[PASS]] + [--server-host SERVER_HOST] [--server-port SERVER_PORT] [--debug] + {create,delete,ls,frames,dump,upload,export,import} ... Perform common operations related to CVAT tasks. diff --git a/site/content/en/docs/api_sdk/sdk/_index.md b/site/content/en/docs/api_sdk/sdk/_index.md new file mode 100644 index 000000000000..870ad1477b4f --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/_index.md @@ -0,0 +1,48 @@ +--- +title: 'CVAT Python SDK' +linkTitle: 'SDK' +weight: 3 +description: '' +--- + +## Overview + +CVAT SDK is a Python library. It provides you access to Python functions and objects that +simplify server interaction and provide additional functionality like data validation +and serialization. + +SDK API includes 2 layers: +- Low-level API with REST API wrappers. Located at `cvat_sdk.api_client`. [Read more](/docs/api_sdk/sdk/lowlevel-api) +- High-level API. Located at `cvat_sdk.core`. [Read more](/docs/api_sdk/sdk/highlevel-api) + +In general, the low-level API provides single-request operations, while the high-level one +implements composite, multi-request operations, and provides local proxies for server objects. +For most uses, the high-level API should be good enough, and it should be +the right point to start your integration with CVAT. + +## Installation + +To install an [official release of CVAT SDK](https://pypi.org/project/cvat-sdk/) use this command: +```bash +pip install cvat-sdk +``` + +We support Python versions 3.7 - 3.9. + +## Usage + +To import package components, use the following code: + +For the high-level API: + +```python +import cvat_sdk +# or +import cvat_sdk.core +``` + +For the low-level API: + +```python +import cvat_sdk.api_client +``` diff --git a/site/content/en/docs/api_sdk/sdk/developer-guide.md b/site/content/en/docs/api_sdk/sdk/developer-guide.md new file mode 100644 index 000000000000..7a5cf12b6cf5 --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/developer-guide.md @@ -0,0 +1,165 @@ +--- +title: 'Developer guide' +linkTitle: 'Developer guide' +weight: 10 +description: '' +--- + +## Overview + +This package contains manually written and autogenerated files. We store only sources in +the repository. To get the full package, one need to generate missing package files. + +## Package file layout + +- `gen/` - generator files +- `cvat_sdk/` - Python package root +- `cvat_sdk/api_client` - autogenerated low-level package code +- `cvat_sdk/core` - high-level package code + +## How to generate package code + +1. Obtain the server API schema + +If you have a local custom version of the server, run the following command in the terminal. +You need to be able to execute django server. Server installation instructions are available +[here](/docs/contributing/development-environment). +```bash +mkdir -p cvat-sdk/schema/ && python manage.py spectacular --file cvat-sdk/schema/schema.yml +``` + +If you want to use docker instead: +```bash +docker-compose -f docker-compose.yml -f docker-compose.dev.yml run \ + --no-deps --entrypoint '/usr/bin/env python' --rm -u "$(id -u)":"$(id -g)" -v "$PWD":"/local" \ + cvat_server \ + manage.py spectacular --file /local/cvat-sdk/schema/schema.yml +``` + +If you don't have access to the server sources, but have a working instance, +you can also get schema from `/api/docs`: + +![Download server schema button image](/images/download_server_schema.png) + +The official server schema for `app.cvat.ai` is available [here](https://app.cvat.ai/api/docs/). + +You can read more about server schema [here](/docs/api_sdk/api#api-schema). + +2. Install generator dependencies: +```bash +pip install -r gen/requirements.txt +``` + +3. Generate package code (call from the package root directory!): +```bash +./gen/generate.sh +``` + +4. Install the packages: + + ```bash + pip install cvat-sdk/ + pip install cvat-cli/ + ``` + + If you want to edit package files, install them with `-e`: + + ```bash + pip install -e cvat-sdk/ + pip install -e cvat-cli/ + ``` + +## How to edit templates + +If you want to edit templates, obtain them from the generator first: + +```bash +docker run --rm -v $PWD:/local \ + openapitools/openapi-generator-cli author template \ + -o /local/generator_templates -g python +``` + +Then, you can copy the modified version of the template you need into +the `gen/templates/openapi-generator/` directory. + +Relevant links: +- [Generator implementation, available variables in templates](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/java/org/openapitools/codegen) +- [Mustache syntax in the generator](https://github.com/OpenAPITools/openapi-generator/wiki/Mustache-Template-Variables) + +## How to test + +API client tests are integrated into REST API tests in `/tests/python/rest_api` +and SDK tests are placed next to them in `/tests/python/sdk`. +To execute, run: +```bash +pytest tests/python/rest_api tests/python/sdk +``` + +## SDK API design decisions + +The generated `ApiClient` code is modified from what `openapi-generator` does by default. +Changes are mostly focused on better user experience - including better +usage patterns and simpler/faster ways to achieve results. + +### Modifications + +- Added Python type annotations for return types and class members. + This change required us to implement a custom post-processing script, + which converts generated types into correct type annotations. The types + generated by default are supposed to work with the API implementation + (parameter validation and parsing), but they are not applicable as + type annotations (they have incorrect syntax). Custom post-processing + allowed us to make these types correct type annotations. + Other possible solutions: + - There is the `python-experimental` API generator, which may solve + some issues, but it is unstable and requires python 3.9. Our API + works with 3.7, which is the lowest supported version now. + - Custom templates - partially works, but only in limited cases + (model fields). It's very hard to maintain the template code and + logic for this. Only `if` checks and `for` loops are available in + mustache templates, which is not enough for annotation generation. + +- Separate APIs are embedded into the general `APIClient` class. + Now we have: + ```python + with ApiClient(config) as api_client: + result1 = api_client.foo_api.operation1() + result2 = api_client.bar_api.operation2() + ``` + + This showed to be more convenient than the default: + ```python + with ApiClient(config) as api_client: + foo_api = FooApi(api_client) + result1 = foo_api.operation1() + result2 = foo_api.operation2() + + bar_api = BarApi(api_client) + result3 = bar_api.operation3() + result4 = bar_api.operation4() + ``` + + This also required custom post-processing. Operation Ids are + [supposed to be unique](https://swagger.io/specification/#operation-object) + in the OpenAPI / Swagger specification. Therefore, we can't generate such + schema on the server, nor we can't expect it to be supported in the + API generator. + +- Operations have IDs like `/_`. + This also showed to be more readable and more natural than DRF-spectacular's + default `/_`. + +- Server operations have different types for input and output values. + While it can be expected that an endopint with POST/PUT methods available + (like `create` or `partial_update`) has the same type for input and output + (because it looks natural), it also leads to the situation, in which there + are lots of read-/write-only fields, and it becomes hard for understanding. + This clear type separation is supposed to make it simpler for users. + +- Added cookie management in the `ApiClient` class. + +- Added interface classes for models to simplify class member usage and lookup. + +- Dicts can be passed into API methods and model constructors instead of models. + They are automatically parsed as models. In the original implementation, the user + is required to pass a `Configuration` object each time, which is clumsy and adds little sense. diff --git a/site/content/en/docs/api_sdk/sdk/highlevel-api.md b/site/content/en/docs/api_sdk/sdk/highlevel-api.md new file mode 100644 index 000000000000..e5391ff3758e --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/highlevel-api.md @@ -0,0 +1,221 @@ +--- +title: 'High-level API' +linkTitle: 'High-level API' +weight: 4 +description: '' +--- + +## Overview + +This layer provides high-level APIs, allowing easier access to server operations. +API includes _Repositories_ and _Entities_. Repositories provide management +operations for Entities. Entities represent objects on the server +(e.g. projects, tasks, jobs etc) and simplify interaction with them. The key difference +from the low-level API is that operations on this layer are not limited by a single +server request per operation and encapsulate low-level request machinery behind a high-level +object-oriented API. + +The code of this component is located in the `cvat_sdk.core` package. + +## Example + +```python +from cvat_sdk import make_client, models +from cvat_sdk.core.proxies.tasks import ResourceType, Task + +# Create a Client instance bound to a local server and authenticate using basic auth +with make_client(host="localhost", credentials=('user', 'password')) as client: + # Let's create a new task. + + # Fill in task parameters first. + # Models are used the same way as in the layer 1. + task_spec = { + "name": "example task", + "labels": [ + { + "name": "car", + "color": "#ff00ff", + "attributes": [ + { + "name": "a", + "mutable": True, + "input_type": "number", + "default_value": "5", + "values": ["4", "5", "6"], + } + ], + } + ], + } + + # Now we can create a task using a task repository method. + # Repositories can be accessed as the Client class members. + # In this case we use 2 local images as the task data. + task = client.tasks.create_from_data( + spec=task_spec, + resource_type=ResourceType.LOCAL, + resources=['image1.jpg', 'image2.png'], + ) + + # The returned task object is already up-to-date with its server counterpart. + # Now we can access task fields. The fields are read-only and can be optional. + # Let's check that we have 2 images in the task data. + assert task.size == 2 + + # If an object is modified on the server, the local object is not updated automatically. + # To reflect the latest changes, the local object needs to be fetch()-ed. + task.fetch() + + # Let's obtain another task. Again, it can be done via the task repository. + # Suppose we have already created the task earlier and know the task id. + task2 = client.tasks.retrieve(42) + + # The task object fields can be update()-d. Note that the set of fields that can be + # modified can be different from what is available for reading. + task2.update({'name': 'my task'}) + + # And the task can also be remove()-d from the server. The local copy will remain + # untouched. + task2.remove() +``` + +## Client + +The `cvat_sdk.core.client.Client` class provides session management, implements +authentication operations and simplifies access to server APIs. +It is the starting point for using CVAT SDK. + +A `Client` instance allows you to: +- configure connection options with the `Config` class +- check server API compatibility with the current SDK version +- deduce server connection scheme (`https` or `http`) automatically +- manage user session with the `login()`, `logout()` and other methods +- obtain Repository objects with the `users`, `tasks`, `jobs` and other members +- reach to lower-level APIs with the corresponding members + +An instance of `Client` can be created directly by calling the class constructor +or with the utility function `cvat_sdk.core.client.make_client()` which can handle +some configuration for you. A `Client` can be configured with +the `cvat_sdk.core.client.Config` class instance. A `Config` object can be passed to +the `Client` constructor and then it will be available in the `Client.config` field. + +The `Client` class implements the [context manager protocol](https://docs.python.org/3/reference/datamodel.html#context-managers). +When the context is closed, the session is finished, and the user is logged out +automatically. Otherwise, these actions can be done with the `close()` and `logout()` methods. + +You can create and start using a `Client` instance this way: + +```python +from cvat_sdk import make_client + +with make_client('localhost', port='8080', credentials=('user', 'password')) as client: + ... +``` + +The `make_client()` function handles configuration and object creation for you. +It also allows to authenticate right after the object is created. + +If you need to configure `Client` parameters, you can do this: + +```python +from cvat_sdk import Config, Client + +config = Config() +# set up some config fields ... + +with Client('localhost:8080', config=config) as client: + client.login(('user', 'password')) + ... +``` + +You can specify server address both with and without the scheme. If the scheme is omitted, +it will be deduced automatically. + +> The checks are performed in the following +order: `https` (with the default port 8080), `http` (with the default port 80). +In some cases it may lead to incorrect results - e.g. you have 2 servers running on the +same host at default ports. In such cases just specify the schema manually: `https://localhost`. + +When the server is located, its version is checked. If an unsupported version is found, +an error can be raised or suppressed (controlled by `config.allow_unsupported_server`). +If the error is suppressed, some SDK functions may not work as expected with this server. +By default, a warning is raised and the error is suppressed. + +> Please note that all `Client` operations rely on the server API and depend on the current user +rights. This affects the set of available APIs, objects and actions. For example, a regular user +can only see and modify their tasks and jobs, while an admin user can see all the tasks etc. + +## Entities and Repositories + +_Entities_ represent objects on the server. They provide read access to object fields +and implement additional relevant operations, including both the general Read-Update-Delete and +object-specific ones. The set of available general operations depends on the object type. + +_Repositories_ provide management operations for corresponding Entities. You don't +need to create Repository objects manually. To obtain a Repository object, use the +corresponding `Client` instance member: + +```python +client.projects +client.tasks +client.jobs +client.users +... +``` + +An Entity can be created on the server with the corresponding Repository method `create()`: + +```python +task = client.tasks.create() +``` + +We can retrieve server objects using the `retrieve()` and `list()` methods of the Repository: + +```python +job = client.jobs.retrieve() +tasks = client.tasks.list() +``` + +After calling these functions, we obtain local objects representing their server counterparts. + +Object fields can be updated with the `update()` method. Note that the set of fields that can be +modified can be different from what is available for reading. + +```python +job.update({'stage': 'validation'}) +``` + +The server object will be updated and the local object will reflect the latest object state +after calling this operation. + +Note that local objects may fall out of sync with their server counterparts for different reasons. +If you need to update the local object with the latest server state, use the `fetch()` method: + +```python +# obtain 2 local copies of the same job +job_ref1 = client.jobs.retrieve(1) +job_ref2 = client.jobs.retrieve(1) + +# update the server object with the first reference +job_ref1.update(...) +# job_ref2 is outdated now + +job_ref2.fetch() +# job_ref2 is synced +``` + +Finally, if you need to remove the object from the server, you can use the `remove()` method. +The server object will be removed, but the local copy of the object will remain untouched. + +```python +task = client.tasks.retrieve() +task.remove() +``` + +Repositories can also provide group operations over entities. For instance, you can retrieve +all available objects using the `list()` Repository method. The list of available +Entity and Repository operations depends on the object type. + +You can learn more about entity members and how model parameters are passed to functions [here](../lowlevel-api). + +The implementation for these components is located in `cvat_sdk.core.proxies`. diff --git a/site/content/en/docs/api_sdk/sdk/lowlevel-api.md b/site/content/en/docs/api_sdk/sdk/lowlevel-api.md new file mode 100644 index 000000000000..49be93022331 --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/lowlevel-api.md @@ -0,0 +1,365 @@ +--- +title: 'Low-level API' +linkTitle: 'Low-level API' +weight: 3 +description: '' +--- + +## Overview + +The low-level API is useful if you need to work directly with REST API, but want +to have data validation and syntax assistance from your code editor. The code +on this layer is autogenerated. + +Code of this component is located in `cvat_sdk.api_client`. + +## Example + +Let's see how a task with local files can be created. We will use the basic auth +to make things simpler. + +```python +from time import sleep +from cvat_sdk.api_client import Configuration, ApiClient, models, apis, exceptions + +configuration = Configuration( + host="http://localhost", + username='YOUR_USERNAME', + password='YOUR_PASSWORD', +) + +# Enter a context with an instance of the API client +with ApiClient(configuration) as api_client: + # Parameters can be passed as a plain dict with JSON-serialized data + # or as model objects (from cvat_sdk.api_client.models), including + # mixed variants. + # + # In case of dicts, keys must be the same as members of models.I + # interfaces and values must be convertible to the corresponding member + # value types (e.g. a date or string enum value can be parsed from a string). + # + # In case of model objects, data must be of the corresponding + # models. types. + # + # Let's use a dict here. It should look like models.ITaskWriteRequest + task_spec = { + 'name': 'example task', + "labels": [{ + "name": "car", + "color": "#ff00ff", + "attributes": [ + { + "name": "a", + "mutable": True, + "input_type": "number", + "default_value": "5", + "values": ["4", "5", "6"] + } + ] + }], + } + + try: + # Apis can be accessed as ApiClient class members + # We use different models for input and output data. For input data, + # models are typically called like "*Request". Output data models have + # no suffix. + (task, response) = api_client.tasks_api.create(task_spec) + except exceptions.ApiException as e: + # We can catch the basic exception type, or a derived type + print("Exception when trying to create a task: %s\n" % e) + + # Here we will use models instead of a dict + task_data = models.DataRequest( + image_quality=75, + client_files=[ + open('image1.jpg', 'rb'), + open('image2.jpg', 'rb'), + ], + ) + + # If we pass binary file objects, we need to specify content type. + # For this endpoint, we don't have response data + (_, response) = api_client.tasks_api.create_data(task.id, + data_request=task_data, + _content_type="multipart/form-data", + + # we can choose to check the response status manually + # and disable the response data parsing + _check_status=False, _parse_response=False + ) + assert response.status == 202, response.msg + + # Wait till task data is processed + for _ in range(100): + (status, _) = api_client.tasks_api.retrieve_status(task.id) + if status.state.value in ['Finished', 'Failed']: + break + sleep(0.1) + assert status.state.value == 'Finished', status.message + + # Update the task object and check the task size + (task, _) = api_client.tasks_api.retrieve(task.id) + assert task.size == 4 +``` + +## ApiClient and configuration + +The starting point in the low-level API is the `cvat_sdk.api_client.ApiClient` class. +It encapsulates session and connection logic, manages headers and cookies, +and provides access to various APIs. + +To create an instance of `ApiClient`, you need to set up a `cvat_sdk.api_client.Configuration` +object and pass it to the `ApiClient` class constructor. Additional connection-specific +options, such as extra headers and cookies can be specified in the class constructor. +`ApiClient` implements the context manager protocol. Typically, you create `ApiClient` this way: + +```python +from cvat_sdk.api_client import ApiClient, Configuration + +configuration = Configuration(host="http://localhost") +with ApiClient(configuration) as api_client: + ... +``` + +After creating an `ApiClient` instance, you can send requests to various server endpoints +via `*_api` member properties and directly, using the `rest_client` member. +[Read more](#api-wrappers) about API wrappers below. + +Typically, the first thing you do with `ApiClient` is log in. +[Read more](#authentication) about authentication options below. + +## Authentication + +CVAT supports 2 authentication options: +- basic auth, with your username and password +- token auth, with your API key + +Token auth requires a token, which can be obtained after performing the basic auth. + +The low-level API supports 2 ways of authentication. +You can specify authentication parameters in the `Configuration` object: + +```python +configuration = Configuration( + username='YOUR_USERNAME', + password='YOUR_PASSWORD', +) +``` + +```python +configuration = Configuration( + api_key={ + "sessionAuth": "", + "csrfAuth": "", + "tokenAuth": "Token ", + } +) +``` + +You can perform a regular login using the `auth_api` member of `ApiClient` and +set the `Authorization` header using the `Token` prefix. This way, you'll be able to +obtain API tokens, which can be reused in the future to avoid typing your credentials. + +```python +from cvat_sdk.api_client import models + +(auth, _) = api_client.auth_api.create_login( + models.LoginRequest(username=credentials[0], password=credentials[1]) +) + +assert "sessionid" in api_client.cookies +assert "csrftoken" in api_client.cookies +api_client.set_default_header("Authorization", "Token " + auth.key) +``` + +## API wrappers + +API endpoints are grouped by tags into separate classes in the `cvat_sdk.api_client.apis` package. + +APIs can be accessed as `ApiClient` object members: + +```python +api_client.auth_api.(...) +api_client.tasks_api.(...) +``` + +And APIs can be instantiated directly like this: + +```python +from cvat_sdk.api_client import ApiClient, apis + +api_client = ApiClient(...) + +auth_api = apis.AuthApi(api_client) +auth_api.(...) + +tasks_api = apis.TasksApi(api_client) +tasks_api.(...) +``` + +For each operation, the API wrapper class has a corresponding `_endpoint` member. +This member represents the endpoint as a first-class object, which provides metainformation +about the endpoint, such as the relative URL of the endpoint, parameter names, +types and their placement in the request. It also allows to pass the operation to other +functions and invoke it from there. + +For a typical server entity like `Task`, `Project`, `Job` etc., the `*Api` classes provide methods +that reflect Create-Read-Update-Delete (CRUD) operations: `create`, `retrieve`, `list`, `update`, +`partial_update`, `delete`. The set of available operations depends on the entity type. + +You can find the list of the available APIs and their documentation [here](../reference/apis/). + +## Models + +Requests and responses can include data. It can be represented as plain Python +data structures and model classes (or models). In CVAT API, model for requests and responses +are separated: the request models have the `Request` suffix in the name, while the response +models have no suffix. Models can be found in the `cvat_sdk.api_client.models` package. + +Models can be instantiated like this: + +```python +from cvat_sdk.api_client import models + +user_model = models.User(...) +``` + +Model parameters can be passed as models, or as plain Python data structures. This rule applies +recursively, starting from the method parameters. In particular, this means you can pass +a dict into a method or into a model constructor, and corresponding fields will +be parsed from this data automatically: + +```python +task_spec = models.TaskWriteRequest( + name='example task', + labels=[ + models.PatchedLabelRequest( + name="car", + color="#ff00ff", + attributes=[ + model.AttributeRequest( + name="a", + mutable=True, + input_type="number", + default_value="5", + values=["4", "5", "6"] + ) + ] + ) + ], +) +api_client.tasks_api.create(task_spec) +``` + +Is equivalent to: + +```python +api_client.tasks_api.create({ + 'name': 'example task', + "labels": [{ + "name": "car", + "color": "#ff00ff", + "attributes": [ + { + "name": "a", + "mutable": True, + "input_type": "number", + "default_value": "5", + "values": ["4", "5", "6"] + } + ] + }], +}) +``` + +You can mix these variants. + +Most models provide corresponding interface classes called like `I`. They can be +used to implement your own classes or describe APIs. They just provide type annotations +and descriptions for model fields. + +You can export model values to plain Python dicts using the `as_dict()` method and +the `cvat_sdk.api_client.model_utils.to_json()` function. + +You can find the list of the available models and their documentation [here](../reference/models/). + +## Sending requests + +To send a request to a server endpoint, you need to obtain an instance of the corresponding `*Api` +class. You can find summary about available API classes and supported endpoints +[here](../reference/apis). The `*Api` instance object allows to send requests to the relevant +server endpoints. + +By default, all operations return 2 objects: the parsed response data and the response itself. +A typical call looks like this: + +```python +from cvat_sdk.api_client import ApiClient, apis + +with ApiClient(...) as api_client: + ... + (data, response) = api_client.tasks_api.list() + # process the response ... +``` + +Operation parameters can be passed as positional or keyword arguments. There are also several +extra arguments which change invocation logic: + +- `_parse_response` - Allows to enable and disable response data parsing. When enabled, + the response data is parsed into a model or a basic type and returned as the first value. + When disabled, the response is not parsed, and `None` is returned. Can be useful, + for instance, if you need to parse data manually, or if you expect an error in the response. +- `_check_status` - Allows to enable or disable response status checks. When enabled, the + response status code is checked to be positive as defined in the [HTTP standards](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes). + In case of negative status, an exception is raised. +- `_request_timeout` - Allows to control timeout +- `_content_type` - Allows to specify the `Content-Type` header value for the request. Endpoints + can support different content types and behave differently depending on the value. For file + uploads `_content_type="multipart/form-data"` must be specified. + +> **NOTE**: the API is autogenerated. In some cases the server API schema may be incomplete +or underspecified. Please report to us all the problems found. A typical problem is that a +response data can't be parsed automatically due to the incorrect schema. In this case, the +simplest workaround is to disable response parsing using the `_parse_response=False` +method argument. + +You can find many examples of API client usage in REST API tests [here](https://github.com/opencv/cvat/tree/develop/tests/python). + +### Organizations + +To call an operation in the context of an organization, use one of these method arguments: + +- `org` - The unique organization slug +- `org_id`- The organization id + +```python +... +(updated_annotations, response) = api_client.tasks_api.partial_update_annotations( + id=task_id, + org_id=org_id, + action='update', + patched_labeled_data_request=data +) +``` + +### Paginated responses + +There are several endpoints that allow to request multiple server entities. Typically, these +endpoints are called `list_...`. When there are lots of data, the responses can be paginated to +reduce server load. If an endpoint returns paginated data, a single page is returned per request. +In some cases all entries need to be retrieved. CVAT doesn't provide specific API or parameters +for this, so the solution is to write a loop to collect and join data from multiple requests. +SDK provides an utility function for this at `cvat_sdk.core.helpers.get_paginated_collection()`. + +Example: + +```python +from cvat_sdk.core.helpers import get_paginated_collection + +... +project_tasks = get_paginated_collection( + api_client.projects_api.list_tasks_endpoint, + id=project_id, +) +``` diff --git a/site/content/en/docs/api_sdk/sdk/reference/_index.md b/site/content/en/docs/api_sdk/sdk/reference/_index.md new file mode 100644 index 000000000000..e91695800b7d --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/reference/_index.md @@ -0,0 +1,6 @@ +--- +title: "SDK API Reference" +linkTitle: "API Reference" +weight: 1 +description: '' +--- diff --git a/site/content/en/docs/api_sdk/sdk/reference/apis/.gitignore b/site/content/en/docs/api_sdk/sdk/reference/apis/.gitignore new file mode 100644 index 000000000000..d21dcbb621e0 --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/reference/apis/.gitignore @@ -0,0 +1,2 @@ +# The files are autogenerated here +*.md diff --git a/site/content/en/docs/api_sdk/sdk/reference/models/.gitignore b/site/content/en/docs/api_sdk/sdk/reference/models/.gitignore new file mode 100644 index 000000000000..3b4f48f540e3 --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/reference/models/.gitignore @@ -0,0 +1,3 @@ +# The files are autogenerated here +*.md +!_index.md diff --git a/site/content/en/docs/api_sdk/sdk/reference/models/_index.md b/site/content/en/docs/api_sdk/sdk/reference/models/_index.md new file mode 100644 index 000000000000..ef978c2f3795 --- /dev/null +++ b/site/content/en/docs/api_sdk/sdk/reference/models/_index.md @@ -0,0 +1,6 @@ +--- +title: 'Models' +linkTitle: 'Models' +weight: 1 +description: '' +--- diff --git a/site/content/en/docs/contributing/_index.md b/site/content/en/docs/contributing/_index.md index bf75235b2524..400f52313f00 100644 --- a/site/content/en/docs/contributing/_index.md +++ b/site/content/en/docs/contributing/_index.md @@ -1,7 +1,7 @@ --- title: 'Contributing to this project' linkTitle: 'Contributing' -weight: 4 +weight: 6 description: 'This section contains documents for CVAT developers.' --- diff --git a/site/content/en/docs/contributing/running-tests.md b/site/content/en/docs/contributing/running-tests.md index 7435ebe938a8..a6ccf1b19ceb 100644 --- a/site/content/en/docs/contributing/running-tests.md +++ b/site/content/en/docs/contributing/running-tests.md @@ -14,6 +14,7 @@ description: 'Instructions on how to run all existence tests.' -f docker-compose.yml \ -f docker-compose.dev.yml \ -f components/serverless/docker-compose.serverless.yml \ + -f tests/docker-compose.minio.yml \ -f tests/docker-compose.file_share.yml up -d ``` 1. Add test user in CVAT: diff --git a/site/content/en/docs/contributing/setup-additional-components.md b/site/content/en/docs/contributing/setup-additional-components.md index abd717ae5998..f403b5a536c1 100644 --- a/site/content/en/docs/contributing/setup-additional-components.md +++ b/site/content/en/docs/contributing/setup-additional-components.md @@ -53,7 +53,7 @@ nuctl deploy --project-name cvat \ ```bash nuctl deploy --project-name cvat \ --path serverless/openvino/omz/public/yolo-v3-tf/nuclio \ - --volume `pwd`/serverless/openvino/common:/opt/nuclio/common \ + --volume `pwd`/serverless/common:/opt/nuclio/common \ --platform local ``` diff --git a/site/content/en/docs/faq.md b/site/content/en/docs/faq.md index df9042f47576..2265b3a2bad2 100644 --- a/site/content/en/docs/faq.md +++ b/site/content/en/docs/faq.md @@ -1,7 +1,7 @@ --- title: 'Frequently asked questions' linkTitle: 'FAQ' -weight: 20 +weight: 2 description: 'Answers to frequently asked questions' --- @@ -175,3 +175,9 @@ Follow the [backup/restore guide](/docs/administration/advanced/backup_guide/#ho ## How to load your own DL model into CVAT See the information here in the [Serverless tutorial](/docs/manual/advanced/serverless-tutorial/#adding-your-own-dl-models). + +## My server uses a custom SSL certificate and I don't want to check it. + +You can call control SSL certificate check with the `--insecure` CLI argument. +For SDK, you can specify `ssl_verify = True/False` in the `cvat_sdk.core.client.Config` object. + diff --git a/site/content/en/docs/manual/_index.md b/site/content/en/docs/manual/_index.md index c0b2ded5c2dd..78926ac0d5a2 100644 --- a/site/content/en/docs/manual/_index.md +++ b/site/content/en/docs/manual/_index.md @@ -1,6 +1,6 @@ --- title: 'Manual' linkTitle: 'Manual' -weight: 2 +weight: 3 description: 'This section contains documents for CVAT simple and advanced users' --- diff --git a/site/content/en/docs/manual/advanced/backup.md b/site/content/en/docs/manual/advanced/backup.md index 6ee41be52ace..d3a6f7d47e0e 100644 --- a/site/content/en/docs/manual/advanced/backup.md +++ b/site/content/en/docs/manual/advanced/backup.md @@ -4,16 +4,57 @@ linkTitle: 'Backup' weight: 17 --- +## Overview + In CVAT you can backup tasks and projects. This can be used to backup a task or project on your PC or to transfer to another server. -## Backup +## Create backup To backup a task or project, open the action menu and select `Backup Task` or `Backup Project`. ![](/images/image219.jpg) -### Backup structure +You can backup a project or a task locally on your PC or using an [attached cloud storage](/docs/manual/basics/attach-cloud-storage/). + +(Optional) Specify the name in the `Custom name` text field for backup, otherwise the file of backup name +will be given by the mask `project__backup__