From af0543330ece385011d44f0dc3f80e156dad842f Mon Sep 17 00:00:00 2001 From: Bastien Abadie <abadie@teklia.com> Date: Wed, 28 Feb 2024 13:53:27 +0000 Subject: [PATCH] Community Version --- Makefile | 7 +- README.md | 5 + arkindex/documents/api/elements.py | 11 +- arkindex/documents/fixtures/data.json | 1962 ++++++------ .../documents/tests/test_allowed_metadata.py | 124 +- .../tests/test_bulk_classification.py | 26 +- .../tests/test_bulk_element_transcriptions.py | 41 +- .../documents/tests/test_bulk_elements.py | 30 +- .../tests/test_bulk_transcription_entities.py | 20 +- .../tests/test_bulk_transcriptions.py | 17 +- arkindex/documents/tests/test_classes.py | 13 +- .../documents/tests/test_classification.py | 50 +- arkindex/documents/tests/test_corpus.py | 25 +- .../tests/test_corpus_authorized_users.py | 5 + .../documents/tests/test_create_elements.py | 54 +- .../tests/test_create_parent_selection.py | 19 +- .../tests/test_create_transcriptions.py | 43 +- .../documents/tests/test_destroy_elements.py | 53 +- .../tests/test_destroy_worker_results.py | 55 +- .../tests/test_edit_transcriptions.py | 19 +- .../documents/tests/test_element_paths_api.py | 14 +- arkindex/documents/tests/test_element_type.py | 52 +- arkindex/documents/tests/test_entities_api.py | 200 +- arkindex/documents/tests/test_entity_types.py | 134 +- arkindex/documents/tests/test_export.py | 64 +- arkindex/documents/tests/test_metadata.py | 158 +- arkindex/documents/tests/test_move_element.py | 19 +- .../documents/tests/test_move_selection.py | 19 +- arkindex/documents/tests/test_neighbors.py | 15 +- .../documents/tests/test_patch_elements.py | 21 +- arkindex/documents/tests/test_put_elements.py | 24 +- .../documents/tests/test_retrieve_elements.py | 16 +- arkindex/documents/tests/test_search_api.py | 26 +- .../documents/tests/test_selection_api.py | 17 +- .../documents/tests/test_transcriptions.py | 34 +- arkindex/images/tests/test_image_elements.py | 20 +- arkindex/ponos/admin.py | 163 +- arkindex/ponos/api.py | 287 +- arkindex/ponos/authentication.py | 68 +- arkindex/ponos/keys.py | 112 - arkindex/ponos/management/__init__.py | 0 .../ponos/management/commands/__init__.py | 0 .../commands/generate_private_key.py | 19 - arkindex/ponos/migrations/0001_initial.py | 14 +- arkindex/ponos/models.py | 253 +- arkindex/ponos/renderers.py | 19 - arkindex/ponos/serializers.py | 392 +-- arkindex/ponos/tasks.py | 161 + arkindex/ponos/tests/test_admin.py | 47 - arkindex/ponos/tests/test_api.py | 2753 ++--------------- arkindex/ponos/tests/test_artifacts_api.py | 152 +- arkindex/ponos/tests/test_keys.py | 89 - arkindex/ponos/tests/test_models.py | 96 +- arkindex/ponos/tests/test_rq_tasks.py | 53 + .../ponos/tests/test_tasks_attribution.py | 481 --- arkindex/ponos/utils.py | 14 + arkindex/process/admin.py | 5 +- arkindex/process/api.py | 8 +- arkindex/process/builder.py | 3 +- arkindex/process/models.py | 6 + arkindex/process/serializers/imports.py | 13 +- arkindex/process/serializers/ingest.py | 2 +- .../process/tests/test_corpus_worker_runs.py | 11 +- arkindex/process/tests/test_create_process.py | 42 +- .../process/tests/test_create_s3_import.py | 40 +- arkindex/process/tests/test_datafile_api.py | 3 +- .../process/tests/test_process_datasets.py | 128 +- .../process/tests/test_process_elements.py | 19 +- arkindex/process/tests/test_processes.py | 243 +- arkindex/process/tests/test_repos.py | 35 +- arkindex/process/tests/test_signals.py | 188 +- arkindex/process/tests/test_templates.py | 34 +- .../process/tests/test_user_workerruns.py | 50 +- arkindex/process/tests/test_utils.py | 67 - .../tests/test_worker_configurations.py | 291 +- arkindex/process/tests/test_workeractivity.py | 7 +- .../tests/test_workeractivity_stats.py | 17 +- arkindex/process/tests/test_workerruns.py | 240 +- arkindex/process/tests/test_workers.py | 433 ++- arkindex/process/utils.py | 20 +- arkindex/project/api_v1.py | 45 +- arkindex/project/checks.py | 17 - arkindex/project/config.py | 5 +- arkindex/project/mixins.py | 30 +- arkindex/project/rq_overrides.py | 2 +- arkindex/project/serializer_fields.py | 11 + arkindex/project/settings.py | 35 +- .../tests/config_samples/defaults.yaml | 3 +- .../project/tests/config_samples/errors.yaml | 1 + .../tests/config_samples/expected_errors.yaml | 1 + .../tests/config_samples/override.yaml | 5 +- arkindex/project/tests/openapi/test_schema.py | 1 - arkindex/project/tests/test_acl_mixin.py | 128 +- arkindex/project/tools.py | 21 - arkindex/project/triggers.py | 15 + arkindex/sql_validation/corpus_delete.sql | 2 +- .../corpus_delete_top_level_type.sql | 2 +- arkindex/training/api.py | 4 +- arkindex/training/tests/test_datasets_api.py | 284 +- arkindex/training/tests/test_metrics_api.py | 67 +- arkindex/training/tests/test_model_api.py | 401 +-- .../tests/test_model_compatible_worker.py | 133 +- arkindex/users/admin.py | 36 +- arkindex/users/allow_all.py | 28 + arkindex/users/api.py | 332 +- arkindex/users/serializers.py | 202 +- .../users/tests/test_generic_memberships.py | 1078 ------- arkindex/users/tests/test_manager_acl.py | 49 - arkindex/users/tests/test_registration.py | 27 +- arkindex/users/utils.py | 123 +- requirements.txt | 2 + 111 files changed, 3852 insertions(+), 9728 deletions(-) delete mode 100644 arkindex/ponos/keys.py delete mode 100644 arkindex/ponos/management/__init__.py delete mode 100644 arkindex/ponos/management/commands/__init__.py delete mode 100644 arkindex/ponos/management/commands/generate_private_key.py delete mode 100644 arkindex/ponos/renderers.py delete mode 100644 arkindex/ponos/tests/test_admin.py delete mode 100644 arkindex/ponos/tests/test_keys.py create mode 100644 arkindex/ponos/tests/test_rq_tasks.py delete mode 100644 arkindex/ponos/tests/test_tasks_attribution.py delete mode 100644 arkindex/process/tests/test_utils.py create mode 100644 arkindex/users/allow_all.py delete mode 100644 arkindex/users/tests/test_generic_memberships.py delete mode 100644 arkindex/users/tests/test_manager_acl.py diff --git a/Makefile b/Makefile index 93765fb75c..dd13c5dd74 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,10 @@ clean: find . -name '*.pyc' -exec rm {} \; build: - CI_PROJECT_DIR=$(ROOT_DIR) CI_REGISTRY_IMAGE=$(IMAGE_TAG) $(ROOT_DIR)/ci/build.sh Dockerfile - -binary: - CI_PROJECT_DIR=$(ROOT_DIR) CI_REGISTRY_IMAGE=$(IMAGE_TAG) $(ROOT_DIR)/ci/build.sh Dockerfile.binary -binary + CI_PROJECT_DIR=$(ROOT_DIR) CI_REGISTRY_IMAGE=$(IMAGE_TAG) $(ROOT_DIR)/ci/build.sh worker: - arkindex/manage.py rqworker -v 2 default high + arkindex/manage.py rqworker -v 2 default high tasks test-fixtures: $(eval export PGPASSWORD=devdata) diff --git a/README.md b/README.md index 2721513724..1209bef98d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,11 @@ We use [rq](https://python-rq.org/), integrated via [django-rq](https://pypi.org To run them, use `make worker` to start a RQ worker. You will need to have Redis running; `make slim` or `make` in the architecture will provide it. `make` in the architecture also provides a RQ worker running in Docker from a binary build. +Process tasks are run in RQ by default (Community Edition). Two RQ workers must be running at the same time to actually run a process with worker activities, so the initialisation task can wait for the worker activity task to finish: +```sh +$ manage.py rqworker -v 3 default high & manage.py rqworker -v 3 tasks +``` + ## Metrics The application serves metrics for Prometheus under the `/metrics` prefix. A specific port can be used by setting the `PROMETHEUS_METRICS_PORT` environment variable, thus separating the application from the metrics API. diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py index 6b5f1925ba..141794ab37 100644 --- a/arkindex/documents/api/elements.py +++ b/arkindex/documents/api/elements.py @@ -1303,17 +1303,16 @@ class ElementNeighbors(ACLMixin, ListAPIView): queryset = Element.objects.none() def get_queryset(self): - element = get_object_or_404( # Include the attributes required for ACL checks and the API response - Element.objects.select_related("corpus", "type").only("id", "name", "type__slug", "corpus__public"), + Element + .objects + .filter(corpus__in=Corpus.objects.readable(self.request.user)) + .select_related("corpus", "type") + .only("id", "name", "type__slug", "corpus__public"), id=self.kwargs["pk"] ) - # Check access permission - if not self.has_access(element.corpus, Role.Guest.value): - raise PermissionDenied(detail="You do not have a read access to this element.") - return Element.objects.get_neighbors(element) diff --git a/arkindex/documents/fixtures/data.json b/arkindex/documents/fixtures/data.json index 20d3e109a8..9e60c75219 100644 --- a/arkindex/documents/fixtures/data.json +++ b/arkindex/documents/fixtures/data.json @@ -1,19 +1,19 @@ [ { "model": "process.process", - "pk": "245bc206-350c-43d5-8db3-d98645c4eaa9", + "pk": "26050593-712f-4f7a-b15a-4d2132241514", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "name": null, - "creator": 1, - "corpus": null, - "mode": "repository", + "name": "Process fixture", + "creator": 2, + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "mode": "workers", "revision": null, "activity_state": "disabled", "started": null, "finished": null, - "farm": "c19116b6-866f-4bb7-b447-3544629a8151", + "farm": "94055f35-9ac9-44a0-ac5d-386e132bea2c", "element": null, "folder_type": null, "element_type": null, @@ -32,19 +32,19 @@ }, { "model": "process.process", - "pk": "64b06ffd-a8c4-4ba2-bbb5-c99eb99121a0", + "pk": "3421ba72-b14c-4df0-a504-1e7e90abe4b4", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "name": "Process fixture", - "creator": 2, - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "mode": "workers", + "name": null, + "creator": 1, + "corpus": null, + "mode": "repository", "revision": null, "activity_state": "disabled", "started": null, "finished": null, - "farm": "c19116b6-866f-4bb7-b447-3544629a8151", + "farm": "94055f35-9ac9-44a0-ac5d-386e132bea2c", "element": null, "folder_type": null, "element_type": null, @@ -63,7 +63,7 @@ }, { "model": "process.process", - "pk": "98e30036-bd74-4480-82c0-98c3199a4503", + "pk": "4a3c1857-7fe7-47ba-9e47-7ce96eefc96f", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", @@ -94,7 +94,7 @@ }, { "model": "process.process", - "pk": "e395a111-ab5a-4c93-8901-b1f8bafde684", + "pk": "be97d04e-0af4-4043-9874-d20f28b30431", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", @@ -125,182 +125,184 @@ }, { "model": "process.repository", - "pk": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "pk": "0e1654c4-20e5-4d4a-99ab-7142040b6e88", "fields": { - "url": "http://my_repo.fake/workers/worker" + "url": "http://gitlab/repo" } }, { "model": "process.repository", - "pk": "b44445fb-1586-4673-b61c-c8057ccc9878", + "pk": "a091592f-0ea9-4147-be4e-4f580751ac9f", "fields": { - "url": "http://gitlab/repo" + "url": "http://my_repo.fake/workers/worker" } }, { "model": "process.revision", - "pk": "5f17c014-45e6-416e-9a6e-86a5de0f79c5", + "pk": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "repo": "b44445fb-1586-4673-b61c-c8057ccc9878", - "hash": "42", - "message": "Salve", - "author": "Some user" + "repo": "a091592f-0ea9-4147-be4e-4f580751ac9f", + "hash": "1337", + "message": "My w0rk3r", + "author": "Test user" } }, { "model": "process.revision", - "pk": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "pk": "d2d52ec9-2d56-4df1-9118-ed884f26cb83", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "repo": "6a07d21c-cc43-4c2b-8c72-5105a0399055", - "hash": "1337", - "message": "My w0rk3r", - "author": "Test user" + "repo": "0e1654c4-20e5-4d4a-99ab-7142040b6e88", + "hash": "42", + "message": "Salve", + "author": "Some user" } }, { "model": "process.worker", - "pk": "19c68046-9fde-4aa6-804d-d3888205722a", + "pk": "5a6c0c92-b9b1-4a69-a8d0-b75841cd0193", "fields": { - "name": "Generic worker with a Model", - "slug": "generic", - "type": "189c3b32-1172-4a79-b9a2-3cb14f7f52e3", + "name": "File import", + "slug": "file_import", + "type": "2c4d9006-9c1f-4a62-944d-a8862aa022b1", "description": "", - "repository": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "repository": "a091592f-0ea9-4147-be4e-4f580751ac9f", "public": false, "archived": null } }, { "model": "process.worker", - "pk": "1ab3c191-f306-4c6b-89b5-5d41a50a3d22", + "pk": "7458bec4-665b-4e2d-862f-7d87bc833481", "fields": { - "name": "Document layout analyser", - "slug": "dla", - "type": "c804b4ef-b2ed-4b82-90c6-fb020e3542d8", + "name": "Custom worker", + "slug": "custom", + "type": "9785b33d-d517-4a91-a4d9-b192e6b042b6", "description": "", - "repository": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "repository": null, "public": false, "archived": null } }, { "model": "process.worker", - "pk": "5a12340d-c2b9-4d28-800b-6563fb9f6bec", + "pk": "89964ea9-97a3-4edf-8568-545ea810342e", "fields": { - "name": "Recognizer", - "slug": "reco", - "type": "189c3b32-1172-4a79-b9a2-3cb14f7f52e3", + "name": "Document layout analyser", + "slug": "dla", + "type": "3b3adc81-73f1-4e92-8093-d1dd9431c53f", "description": "", - "repository": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "repository": "a091592f-0ea9-4147-be4e-4f580751ac9f", "public": false, "archived": null } }, { "model": "process.worker", - "pk": "62666365-d37d-4e30-91ad-723405dbdc8e", + "pk": "c7439e52-9b09-489b-8dc5-13994801e8ac", "fields": { "name": "Worker requiring a GPU", "slug": "worker-gpu", - "type": "429e7774-99cd-4672-9438-7d576a69a1a4", + "type": "4696efe7-e3dd-4a8c-8346-c99d479c1756", "description": "", - "repository": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "repository": "a091592f-0ea9-4147-be4e-4f580751ac9f", "public": false, "archived": null } }, { "model": "process.worker", - "pk": "bf76d80a-ca52-47e2-8670-2d60c1a675b0", + "pk": "c88cf97b-25a1-4074-a88c-8d2b7a82db8c", "fields": { - "name": "Custom worker", - "slug": "custom", - "type": "2b2c3dae-98be-42c3-927b-28440f8df5d0", + "name": "Recognizer", + "slug": "reco", + "type": "999707eb-4c53-469d-8fb6-ec6a80010248", "description": "", - "repository": null, + "repository": "a091592f-0ea9-4147-be4e-4f580751ac9f", "public": false, "archived": null } }, { "model": "process.worker", - "pk": "e61fde1d-e5bb-4188-aefa-1ceb04fa34a7", + "pk": "f62b2ea5-7bff-4c38-9bd1-7237543cad36", "fields": { - "name": "File import", - "slug": "file_import", - "type": "2164e147-3109-4a68-93d7-d68341dedfb2", + "name": "Generic worker with a Model", + "slug": "generic", + "type": "999707eb-4c53-469d-8fb6-ec6a80010248", "description": "", - "repository": "6a07d21c-cc43-4c2b-8c72-5105a0399055", + "repository": "a091592f-0ea9-4147-be4e-4f580751ac9f", "public": false, "archived": null } }, { "model": "process.workertype", - "pk": "189c3b32-1172-4a79-b9a2-3cb14f7f52e3", + "pk": "2c4d9006-9c1f-4a62-944d-a8862aa022b1", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "slug": "recognizer", - "display_name": "Recognizer" + "slug": "import", + "display_name": "Import" } }, { "model": "process.workertype", - "pk": "2164e147-3109-4a68-93d7-d68341dedfb2", + "pk": "3b3adc81-73f1-4e92-8093-d1dd9431c53f", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "slug": "import", - "display_name": "Import" + "slug": "dla", + "display_name": "Document Layout Analysis" } }, { "model": "process.workertype", - "pk": "2b2c3dae-98be-42c3-927b-28440f8df5d0", + "pk": "4696efe7-e3dd-4a8c-8346-c99d479c1756", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "slug": "custom", - "display_name": "Custom" + "slug": "worker", + "display_name": "Worker requiring a GPU" } }, { "model": "process.workertype", - "pk": "429e7774-99cd-4672-9438-7d576a69a1a4", + "pk": "9785b33d-d517-4a91-a4d9-b192e6b042b6", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "slug": "worker", - "display_name": "Worker requiring a GPU" + "slug": "custom", + "display_name": "Custom" } }, { "model": "process.workertype", - "pk": "c804b4ef-b2ed-4b82-90c6-fb020e3542d8", + "pk": "999707eb-4c53-469d-8fb6-ec6a80010248", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "slug": "dla", - "display_name": "Document Layout Analysis" + "slug": "recognizer", + "display_name": "Recognizer" } }, { "model": "process.workerversion", - "pk": "1e0dee90-0973-4017-b23d-4d972d7cfc09", + "pk": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "fields": { - "worker": "e61fde1d-e5bb-4188-aefa-1ceb04fa34a7", - "revision": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "worker": "c88cf97b-25a1-4074-a88c-8d2b7a82db8c", + "revision": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "version": null, - "configuration": {}, + "configuration": { + "test": 42 + }, "state": "available", "gpu_usage": "disabled", "model_usage": "disabled", - "docker_image": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "docker_image": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "docker_image_iid": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z" @@ -308,9 +310,9 @@ }, { "model": "process.workerversion", - "pk": "5ce2dcd8-f35e-41d1-bd73-b40c9661db77", + "pk": "23473bff-4362-466e-ba5f-b6843df1fdf3", "fields": { - "worker": "bf76d80a-ca52-47e2-8670-2d60c1a675b0", + "worker": "7458bec4-665b-4e2d-862f-7d87bc833481", "revision": null, "version": 1, "configuration": { @@ -327,18 +329,18 @@ }, { "model": "process.workerversion", - "pk": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "pk": "2aa45507-c5f2-4e1f-bc38-edf50dbf5aae", "fields": { - "worker": "5a12340d-c2b9-4d28-800b-6563fb9f6bec", - "revision": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "worker": "f62b2ea5-7bff-4c38-9bd1-7237543cad36", + "revision": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "version": null, "configuration": { "test": 42 }, "state": "available", "gpu_usage": "disabled", - "model_usage": "disabled", - "docker_image": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "model_usage": "required", + "docker_image": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "docker_image_iid": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z" @@ -346,10 +348,10 @@ }, { "model": "process.workerversion", - "pk": "79d83370-d164-4052-bb29-18f475916ea4", + "pk": "66d545f5-4f60-436a-9cc3-517504cce152", "fields": { - "worker": "62666365-d37d-4e30-91ad-723405dbdc8e", - "revision": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "worker": "c7439e52-9b09-489b-8dc5-13994801e8ac", + "revision": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "version": null, "configuration": { "test": 42 @@ -357,7 +359,7 @@ "state": "available", "gpu_usage": "required", "model_usage": "disabled", - "docker_image": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "docker_image": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "docker_image_iid": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z" @@ -365,18 +367,18 @@ }, { "model": "process.workerversion", - "pk": "9e43ea6a-3f53-4a54-9a66-8f1699570f59", + "pk": "7e54d670-f1f2-4b96-a38a-ac6a5416ea72", "fields": { - "worker": "19c68046-9fde-4aa6-804d-d3888205722a", - "revision": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "worker": "89964ea9-97a3-4edf-8568-545ea810342e", + "revision": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "version": null, "configuration": { "test": 42 }, "state": "available", "gpu_usage": "disabled", - "model_usage": "required", - "docker_image": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "model_usage": "disabled", + "docker_image": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "docker_image_iid": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z" @@ -384,18 +386,16 @@ }, { "model": "process.workerversion", - "pk": "b0db756e-b291-4ffe-99c7-cd347741d7db", + "pk": "88a1c9ce-3128-4219-a120-9ea9574316a3", "fields": { - "worker": "1ab3c191-f306-4c6b-89b5-5d41a50a3d22", - "revision": "88be293e-5e58-4b60-8438-3feaf38c0f12", + "worker": "5a6c0c92-b9b1-4a69-a8d0-b75841cd0193", + "revision": "539e2eb4-6180-4d45-bc2f-ddd4bbe08ab1", "version": null, - "configuration": { - "test": 42 - }, + "configuration": {}, "state": "available", "gpu_usage": "disabled", "model_usage": "disabled", - "docker_image": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "docker_image": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "docker_image_iid": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z" @@ -403,14 +403,14 @@ }, { "model": "process.workerrun", - "pk": "4b931fc3-4c2b-44ea-8743-4907915554bb", + "pk": "796a41f5-9797-443d-be13-ea73372e4950", "fields": { - "process": "64b06ffd-a8c4-4ba2-bbb5-c99eb99121a0", - "version": "b0db756e-b291-4ffe-99c7-cd347741d7db", + "process": "4a3c1857-7fe7-47ba-9e47-7ce96eefc96f", + "version": "23473bff-4362-466e-ba5f-b6843df1fdf3", "model_version": null, "parents": "[]", "configuration": null, - "summary": "Worker Document layout analyser @ b0db75", + "summary": "Worker Custom worker @ version 1", "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "has_results": false @@ -418,14 +418,14 @@ }, { "model": "process.workerrun", - "pk": "4d26aa77-969b-4734-9260-a52b63d6a1d6", + "pk": "9b99e704-47e6-478c-988f-323c636bb91a", "fields": { - "process": "64b06ffd-a8c4-4ba2-bbb5-c99eb99121a0", - "version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "process": "26050593-712f-4f7a-b15a-4d2132241514", + "version": "7e54d670-f1f2-4b96-a38a-ac6a5416ea72", "model_version": null, - "parents": "[\"4b931fc3-4c2b-44ea-8743-4907915554bb\"]", + "parents": "[]", "configuration": null, - "summary": "Worker Recognizer @ 70b6b8", + "summary": "Worker Document layout analyser @ 7e54d6", "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "has_results": false @@ -433,14 +433,14 @@ }, { "model": "process.workerrun", - "pk": "83959955-af5d-459f-8e33-47b39e256852", + "pk": "810d22b3-90bf-44bb-a931-9c20d6b600ca", "fields": { - "process": "e395a111-ab5a-4c93-8901-b1f8bafde684", - "version": "5ce2dcd8-f35e-41d1-bd73-b40c9661db77", + "process": "26050593-712f-4f7a-b15a-4d2132241514", + "version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "model_version": null, - "parents": "[]", + "parents": "[\"9b99e704-47e6-478c-988f-323c636bb91a\"]", "configuration": null, - "summary": "Worker Custom worker @ version 1", + "summary": "Worker Recognizer @ 06e217", "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "has_results": false @@ -448,10 +448,10 @@ }, { "model": "process.workerrun", - "pk": "86240072-39ba-4597-a7a5-9078f3134657", + "pk": "9c5e8955-084a-4dd4-a816-c74d2ac0ab49", "fields": { - "process": "98e30036-bd74-4480-82c0-98c3199a4503", - "version": "5ce2dcd8-f35e-41d1-bd73-b40c9661db77", + "process": "be97d04e-0af4-4043-9874-d20f28b30431", + "version": "23473bff-4362-466e-ba5f-b6843df1fdf3", "model_version": null, "parents": "[]", "configuration": null, @@ -463,7 +463,7 @@ }, { "model": "documents.corpus", - "pk": "fdbe4b5c-9475-4310-8877-eb07344e294a", + "pk": "1204ee59-2b68-4dfb-a083-3df4996c2337", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", @@ -476,11 +476,11 @@ }, { "model": "documents.elementtype", - "pk": "3bbbd33a-b906-4526-9e0b-0643ade1b080", + "pk": "097f73b9-4ea7-4063-98b0-e17eb698e77a", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "slug": "word", - "display_name": "Word", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "slug": "text_line", + "display_name": "Line", "folder": false, "indexable": false, "color": "28b62c" @@ -488,9 +488,9 @@ }, { "model": "documents.elementtype", - "pk": "4200ad41-d032-476a-acb4-943e6481ee6d", + "pk": "60e8c1f1-c04d-49b6-bc6e-70e38e437f36", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", "slug": "volume", "display_name": "Volume", "folder": true, @@ -500,11 +500,11 @@ }, { "model": "documents.elementtype", - "pk": "634e6aee-87a1-4845-a63e-83e7226aa557", + "pk": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "slug": "text_line", - "display_name": "Line", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "slug": "word", + "display_name": "Word", "folder": false, "indexable": false, "color": "28b62c" @@ -512,11 +512,11 @@ }, { "model": "documents.elementtype", - "pk": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", + "pk": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "slug": "page", - "display_name": "Page", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "slug": "act", + "display_name": "Act", "folder": false, "indexable": false, "color": "28b62c" @@ -524,11 +524,11 @@ }, { "model": "documents.elementtype", - "pk": "bc871e02-a23f-4799-a4d7-bb81813f41f5", + "pk": "f2d75650-9d48-4b6e-b210-dbd595a668d2", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "slug": "surface", - "display_name": "Surface", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "slug": "page", + "display_name": "Page", "folder": false, "indexable": false, "color": "28b62c" @@ -536,11 +536,11 @@ }, { "model": "documents.elementtype", - "pk": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", + "pk": "fb9cf6d3-b31a-449c-a780-2f5db501695f", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "slug": "act", - "display_name": "Act", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "slug": "surface", + "display_name": "Surface", "folder": false, "indexable": false, "color": "28b62c" @@ -548,279 +548,279 @@ }, { "model": "documents.elementpath", - "pk": "04489822-c85b-4ac8-8d59-f7e1ac914295", - "fields": { - "element": "6c6a774e-88c3-40c5-9913-d7ec2a3d5125", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 0 - } -}, -{ - "model": "documents.elementpath", - "pk": "0a801b84-7243-4ee0-857a-446a6af3ae97", + "pk": "13930713-e133-4d00-90fe-7169541b6b8c", "fields": { - "element": "3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64", - "path": "[]", + "element": "1f20a3a4-33be-43a3-9427-393799aba6d7", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"737db4ff-8119-4e12-be99-968d7fa3be22\"]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "0aad9073-0d49-492e-8455-4bd99e492e89", + "pk": "250c8b60-16ac-49c9-ba2a-ee157d387077", "fields": { - "element": "469f4606-f5ea-406d-a0bd-6d48050c39ed", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"4d2f59fe-9808-44b1-b3b5-017490ae4064\"]", - "ordering": 0 + "element": "60a6c062-1bbb-4e5f-82e7-37cb77d92419", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"67482d70-765b-4092-b462-2d955fd8a7df\"]", + "ordering": 2 } }, { "model": "documents.elementpath", - "pk": "2a801688-b438-4234-a1de-4b06553f1d55", + "pk": "2d34a587-68b2-4087-a05e-d92c5bc0b086", "fields": { - "element": "d3dc3f63-9894-434b-9715-7bd906b63e63", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6c6a774e-88c3-40c5-9913-d7ec2a3d5125\"]", - "ordering": 0 + "element": "6452b6c6-3d58-48c2-9032-691095fe8317", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"67482d70-765b-4092-b462-2d955fd8a7df\"]", + "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "394d7eca-1ccc-47a4-ae44-8dd33c9ceb17", + "pk": "33cdaa32-f9fe-4d60-91af-6200f90915af", "fields": { - "element": "60313262-3273-4c39-a8ee-cedc733f6990", - "path": "[\"7a007e29-b509-4c4e-8321-78464df6f03a\"]", - "ordering": 0 + "element": "737db4ff-8119-4e12-be99-968d7fa3be22", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", + "ordering": 2 } }, { "model": "documents.elementpath", - "pk": "3d71eb28-24c5-4028-a72b-a3c170c117eb", + "pk": "344c8906-d207-48cc-9d5b-5c738758e510", "fields": { - "element": "17a65610-023b-46c7-93a6-453335626d17", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6c6a774e-88c3-40c5-9913-d7ec2a3d5125\"]", - "ordering": 3 + "element": "66843ee4-559b-4d2d-a9ce-4dd425022159", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"737db4ff-8119-4e12-be99-968d7fa3be22\"]", + "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "4cd70fa4-1e6e-452a-8212-4be6138a6b34", + "pk": "36612194-8586-43a3-9114-b3fa5d4c7832", "fields": { - "element": "c16df32e-03b5-47e4-868f-98d57eac1f15", - "path": "[\"7a007e29-b509-4c4e-8321-78464df6f03a\"]", - "ordering": 1 + "element": "8349d4ba-fd23-4da6-a99b-76ba2e359b6f", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", + "ordering": 5 } }, { "model": "documents.elementpath", - "pk": "4e407215-0ed2-4ff2-98ff-a22d6b23349a", + "pk": "37f898d5-0a9a-49df-b8f6-40761f18c734", "fields": { - "element": "a8c108a2-a6b7-4401-9d64-65cf263531a7", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6c6a774e-88c3-40c5-9913-d7ec2a3d5125\"]", + "element": "a41e2061-65cf-4c6e-9ea3-48d00d74133d", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"a0373c38-907d-4127-9762-5aada56b7165\"]", "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "56655c48-69b4-4ea0-a724-b10d1a47e231", + "pk": "4450a6b1-88fb-4d11-9c7a-edd03872050d", "fields": { - "element": "b8c4d1e3-aa9d-425a-b70f-c8ff16ae147c", - "path": "[\"7a007e29-b509-4c4e-8321-78464df6f03a\"]", - "ordering": 2 + "element": "7f80cd7d-f24f-4691-a139-0ce744ea7577", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"492ea0b0-bcaf-4a46-a15a-29cd08e32d90\"]", + "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "5c064e36-d3b5-4a8b-8f2d-97da74e84a70", + "pk": "48d847e6-26be-45e8-92a4-0a6517227172", "fields": { - "element": "86f68e65-f23b-4d01-a9ca-c40eed9cda32", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 6 + "element": "8727b32e-6b08-44fd-84ff-54d505fb5a85", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"a0373c38-907d-4127-9762-5aada56b7165\"]", + "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "617f3ea6-17f8-4514-9f87-4c8bdd747e3f", + "pk": "5bb0b100-e35c-4433-884e-16b3c827dea1", "fields": { - "element": "4d2f59fe-9808-44b1-b3b5-017490ae4064", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 2 + "element": "e2e0ab90-85c8-457e-920d-479bebb43bf9", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"67482d70-765b-4092-b462-2d955fd8a7df\"]", + "ordering": 3 } }, { "model": "documents.elementpath", - "pk": "6cc0a005-dbff-4b53-b452-b6d4d8ec8eb3", + "pk": "60fccadc-2668-45f0-af63-8541f9d8000a", "fields": { - "element": "09ee64d6-3d8d-4d74-a1cd-ec34c8c2cd38", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"664f19d6-472d-49cc-bd87-26c19db7d2d3\"]", + "element": "900dcb10-2146-4571-8735-f508eb5f9986", + "path": "[]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "8adb5d49-8651-4239-b98d-425a70ff39cf", + "pk": "6b954042-1718-4794-aad4-55c6e2e9e054", "fields": { - "element": "6a41ddf7-41e4-40ad-8234-d773b74058bc", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 1 + "element": "7cd8a970-ca3a-479f-b49e-1292b216c1b9", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"492ea0b0-bcaf-4a46-a15a-29cd08e32d90\"]", + "ordering": 2 } }, { "model": "documents.elementpath", - "pk": "a2d6b2bd-677e-4f72-9a9d-64957b854a90", + "pk": "6ff93429-322f-486a-bc0f-31f7047ce341", "fields": { - "element": "18ada82e-36c6-47e4-bcff-bee5aa63889b", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"4d2f59fe-9808-44b1-b3b5-017490ae4064\"]", + "element": "c80311e3-4529-45d6-9575-31b362c99925", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"737db4ff-8119-4e12-be99-968d7fa3be22\"]", "ordering": 2 } }, { "model": "documents.elementpath", - "pk": "a3d290d2-c6a1-4c80-8baf-37d42497e61e", + "pk": "8fd6c9e9-3133-4733-b58b-8a093e49288c", "fields": { - "element": "d227268a-2c2e-40ff-b22c-ab6e52eb5bba", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 3 + "element": "f6b8eced-ee41-42d3-812a-a622f1879809", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"8144a0f8-f75b-43fa-ac27-d49d69bc483c\"]", + "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "a735c855-bd4b-4ece-8345-eac1dc791ff9", + "pk": "98f8328f-77b3-490d-aa4c-97d6f2030e3f", "fields": { - "element": "ba888ed5-8c85-4f49-8f87-499219187ade", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 5 + "element": "dc79378e-1804-45c8-b23f-a2eeb33734de", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"8349d4ba-fd23-4da6-a99b-76ba2e359b6f\"]", + "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "a8012a46-1e99-40a4-ad86-d2b2040eb026", + "pk": "9979cf91-e6da-46e4-976e-386cb9ebbc42", "fields": { - "element": "4c054d5e-f30c-4f70-a368-8e6f78f48ba1", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"d227268a-2c2e-40ff-b22c-ab6e52eb5bba\"]", - "ordering": 0 + "element": "7ac6504c-4088-4e6e-9a48-567f1f7e54c8", + "path": "[\"900dcb10-2146-4571-8735-f508eb5f9986\"]", + "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "a8b4a820-4de5-4164-88e9-66b3fd875938", + "pk": "99ac36ba-7c91-4beb-a10b-97d8edffa1d9", "fields": { - "element": "34cdee1d-40e3-4499-b04b-93f6d2bfb802", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6c6a774e-88c3-40c5-9913-d7ec2a3d5125\"]", - "ordering": 2 + "element": "12e84ea0-004a-43e7-9fcc-85021e2407a9", + "path": "[]", + "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "ab00de7e-015c-4f76-a3c0-645cb879a024", + "pk": "9feb4874-46bf-463a-9bc9-71b13af8bcc0", "fields": { - "element": "2c0f28b1-b8e9-4f08-9014-75ce7950ee6f", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6a41ddf7-41e4-40ad-8234-d773b74058bc\"]", + "element": "e38ab791-092f-48eb-bb09-ea95e5a01998", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"492ea0b0-bcaf-4a46-a15a-29cd08e32d90\"]", "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "b100fd6e-d7ed-4481-8604-f07e9d554637", + "pk": "b447f917-b73c-42b5-9512-1a3af17b3eee", "fields": { - "element": "3603393d-4831-4dbf-9672-3fbe1077e03a", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"4d2f59fe-9808-44b1-b3b5-017490ae4064\"]", - "ordering": 1 + "element": "a0373c38-907d-4127-9762-5aada56b7165", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", + "ordering": 4 } }, { "model": "documents.elementpath", - "pk": "b7098cdc-dcba-4efc-8213-e92af55fa6f9", + "pk": "b869ccff-bb9b-4c85-9743-d8930f2d920a", "fields": { - "element": "3f3bb3ee-ec7c-436c-9dde-f8aa0e1a69d7", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", + "element": "f8d56f23-51f6-40dd-9482-939a1dd877b3", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", "ordering": 7 } }, { "model": "documents.elementpath", - "pk": "b992817b-343e-4a5b-bf6d-42cb334696d1", + "pk": "b8a8076c-62d4-4f86-a2f0-21187d589279", "fields": { - "element": "b0a0984b-dcf2-46da-8d6e-933020a46011", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6a41ddf7-41e4-40ad-8234-d773b74058bc\"]", - "ordering": 2 + "element": "8144a0f8-f75b-43fa-ac27-d49d69bc483c", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", + "ordering": 6 } }, { "model": "documents.elementpath", - "pk": "c35f1e16-f92d-4589-afc4-6c3115fa36d7", + "pk": "be089329-1d86-42cb-85b6-3a2520a334ed", "fields": { - "element": "9fd626f8-a5eb-4ae3-9f4f-f0ea4216712e", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"664f19d6-472d-49cc-bd87-26c19db7d2d3\"]", + "element": "492ea0b0-bcaf-4a46-a15a-29cd08e32d90", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", "ordering": 1 } }, { "model": "documents.elementpath", - "pk": "d0586c21-e60a-41d8-9a17-b71eeb75bc68", + "pk": "c66e5e45-f39b-42b7-bbf7-55bab78bfe2c", "fields": { - "element": "e2f8eee0-cf55-404c-86e6-6950ab88faac", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"3f3bb3ee-ec7c-436c-9dde-f8aa0e1a69d7\"]", + "element": "72dd24ed-22f5-40a6-bbb4-b4b0abfcd758", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"f8d56f23-51f6-40dd-9482-939a1dd877b3\"]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "da4fe1fb-7ad7-4b38-973f-c9a4ca8d402f", + "pk": "cc4ca9d4-57ac-4b86-aa08-848fb31c15f1", + "fields": { + "element": "43ba23ed-3e59-466d-b48f-35928d3d9a59", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", + "ordering": 3 + } +}, +{ + "model": "documents.elementpath", + "pk": "d28feecf-d0a3-4bd6-b045-71cae47ccf4f", "fields": { - "element": "3189dc31-fa75-4c60-b1d0-7a15720e35ca", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"86f68e65-f23b-4d01-a9ca-c40eed9cda32\"]", + "element": "67482d70-765b-4092-b462-2d955fd8a7df", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\"]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "e2c73d2f-8881-4b4e-909f-7244abd4c92c", + "pk": "d840ea1c-695a-4e81-bfb1-ccc2c7746979", "fields": { - "element": "664f19d6-472d-49cc-bd87-26c19db7d2d3", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\"]", - "ordering": 4 + "element": "f09bebdb-152e-427c-ae2b-2e296982f342", + "path": "[\"900dcb10-2146-4571-8735-f508eb5f9986\"]", + "ordering": 2 } }, { "model": "documents.elementpath", - "pk": "e6c19e34-49a7-4dc2-aa37-8d96f2a7b43e", + "pk": "dd98d35b-a3dd-48fa-bec0-1e932f9314ff", "fields": { - "element": "7a007e29-b509-4c4e-8321-78464df6f03a", - "path": "[]", + "element": "2aaa4788-127e-464c-bd8c-cd938796d207", + "path": "[\"900dcb10-2146-4571-8735-f508eb5f9986\"]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "e9d64de1-c0c6-44a1-bb33-38c87a144282", + "pk": "de39b960-87d6-4805-bd39-93debf36f7b3", "fields": { - "element": "d5daac1b-5f57-4d3c-b120-54adbcc82d7d", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"ba888ed5-8c85-4f49-8f87-499219187ade\"]", + "element": "ba400ee3-7247-4c20-bef4-3a49ad9e2e77", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"67482d70-765b-4092-b462-2d955fd8a7df\"]", "ordering": 0 } }, { "model": "documents.elementpath", - "pk": "ed3283f8-0af1-467e-8a52-77f9d434af78", + "pk": "f0042f9c-66a9-448d-a202-03b55d40d03d", "fields": { - "element": "92101c2b-885f-42e2-a0cb-13a8a04c0ea8", - "path": "[\"3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64\", \"6a41ddf7-41e4-40ad-8234-d773b74058bc\"]", + "element": "9f1c4e10-d94c-4628-a575-5cb0afdd34af", + "path": "[\"12e84ea0-004a-43e7-9fcc-85021e2407a9\", \"43ba23ed-3e59-466d-b48f-35928d3d9a59\"]", "ordering": 0 } }, { "model": "documents.element", - "pk": "09ee64d6-3d8d-4d74-a1cd-ec34c8c2cd38", + "pk": "12e84ea0-004a-43e7-9fcc-85021e2407a9", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", - "name": "Surface B", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "60e8c1f1-c04d-49b6-bc6e-70e38e437f36", + "name": "Volume 1", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (600 600, 600 1000, 1000 1000, 1000 600, 600 600)", + "image": null, + "polygon": null, "rotation_angle": 0, "mirrored": false, "confidence": null @@ -828,18 +828,18 @@ }, { "model": "documents.element", - "pk": "17a65610-023b-46c7-93a6-453335626d17", + "pk": "1f20a3a4-33be-43a3-9427-393799aba6d7", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "634e6aee-87a1-4845-a63e-83e7226aa557", - "name": "Text line", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "PARIS", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -847,18 +847,18 @@ }, { "model": "documents.element", - "pk": "18ada82e-36c6-47e4-bcff-bee5aa63889b", + "pk": "2aaa4788-127e-464c-bd8c-cd938796d207", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "DATUM", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 2, page 1r", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", - "polygon": "LINEARRING (700 700, 700 800, 800 800, 800 700, 700 700)", + "image": "a68c21d7-d24f-44d7-b134-3c50ee6ddba1", + "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -866,18 +866,18 @@ }, { "model": "documents.element", - "pk": "2c0f28b1-b8e9-4f08-9014-75ce7950ee6f", + "pk": "43ba23ed-3e59-466d-b48f-35928d3d9a59", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "ROY", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", + "name": "Act 1", "creator": null, "worker_version": null, "worker_run": null, - "image": "47f164d3-d26f-41e8-9df1-b538645d60b5", - "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", + "image": null, + "polygon": null, "rotation_angle": 0, "mirrored": false, "confidence": null @@ -885,18 +885,18 @@ }, { "model": "documents.element", - "pk": "3189dc31-fa75-4c60-b1d0-7a15720e35ca", + "pk": "492ea0b0-bcaf-4a46-a15a-29cd08e32d90", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", - "name": "Surface E", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 1, page 1v", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", - "polygon": "LINEARRING (300 300, 300 600, 600 600, 600 300, 300 300)", + "image": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", + "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -904,17 +904,17 @@ }, { "model": "documents.element", - "pk": "34cdee1d-40e3-4499-b04b-93f6d2bfb802", + "pk": "60a6c062-1bbb-4e5f-82e7-37cb77d92419", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", "name": "DATUM", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", + "image": "627a53a6-466c-4114-b826-852ecd328e47", "polygon": "LINEARRING (700 700, 700 800, 800 800, 800 700, 700 700)", "rotation_angle": 0, "mirrored": false, @@ -923,17 +923,17 @@ }, { "model": "documents.element", - "pk": "3603393d-4831-4dbf-9672-3fbe1077e03a", + "pk": "6452b6c6-3d58-48c2-9032-691095fe8317", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", "name": "ROY", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", + "image": "627a53a6-466c-4114-b826-852ecd328e47", "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", "rotation_angle": 0, "mirrored": false, @@ -942,18 +942,18 @@ }, { "model": "documents.element", - "pk": "3bcfeab1-e4d4-4f63-8f60-8fc5fc551a64", + "pk": "66843ee4-559b-4d2d-a9ce-4dd425022159", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "4200ad41-d032-476a-acb4-943e6481ee6d", - "name": "Volume 1", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "ROY", "creator": null, "worker_version": null, "worker_run": null, - "image": null, - "polygon": null, + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -961,18 +961,18 @@ }, { "model": "documents.element", - "pk": "3f3bb3ee-ec7c-436c-9dde-f8aa0e1a69d7", + "pk": "67482d70-765b-4092-b462-2d955fd8a7df", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", - "name": "Act 5", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 1, page 1r", "creator": null, "worker_version": null, "worker_run": null, - "image": null, - "polygon": null, + "image": "627a53a6-466c-4114-b826-852ecd328e47", + "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -980,18 +980,18 @@ }, { "model": "documents.element", - "pk": "469f4606-f5ea-406d-a0bd-6d48050c39ed", + "pk": "72dd24ed-22f5-40a6-bbb4-b4b0abfcd758", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "PARIS", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", + "name": "Surface F", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", - "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (600 600, 600 1000, 1000 1000, 1000 600, 600 600)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -999,18 +999,18 @@ }, { "model": "documents.element", - "pk": "4c054d5e-f30c-4f70-a368-8e6f78f48ba1", + "pk": "737db4ff-8119-4e12-be99-968d7fa3be22", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", - "name": "Surface A", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 1, page 2r", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (0 0, 0 600, 600 600, 600 0, 0 0)", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1018,17 +1018,17 @@ }, { "model": "documents.element", - "pk": "4d2f59fe-9808-44b1-b3b5-017490ae4064", + "pk": "7ac6504c-4088-4e6e-9a48-567f1f7e54c8", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 1, page 2r", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 2, page 1v", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", + "image": "4867c2a1-fadc-4086-842c-c914b678d37d", "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, @@ -1037,18 +1037,18 @@ }, { "model": "documents.element", - "pk": "60313262-3273-4c39-a8ee-cedc733f6990", + "pk": "7cd8a970-ca3a-479f-b49e-1292b216c1b9", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 2, page 1r", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "DATUM", "creator": null, "worker_version": null, "worker_run": null, - "image": "2552f45d-6880-4a4c-b9c2-1091598a52b4", - "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", + "image": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", + "polygon": "LINEARRING (700 700, 700 800, 800 800, 800 700, 700 700)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1056,13 +1056,32 @@ }, { "model": "documents.element", - "pk": "664f19d6-472d-49cc-bd87-26c19db7d2d3", + "pk": "7f80cd7d-f24f-4691-a139-0ce744ea7577", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", - "name": "Act 2", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "PARIS", + "creator": null, + "worker_version": null, + "worker_run": null, + "image": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", + "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", + "rotation_angle": 0, + "mirrored": false, + "confidence": null + } +}, +{ + "model": "documents.element", + "pk": "8144a0f8-f75b-43fa-ac27-d49d69bc483c", + "fields": { + "created": "2020-02-02T01:23:45.678Z", + "updated": "2020-02-02T01:23:45.678Z", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", + "name": "Act 4", "creator": null, "worker_version": null, "worker_run": null, @@ -1075,18 +1094,18 @@ }, { "model": "documents.element", - "pk": "6a41ddf7-41e4-40ad-8234-d773b74058bc", + "pk": "8349d4ba-fd23-4da6-a99b-76ba2e359b6f", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 1, page 1v", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", + "name": "Act 3", "creator": null, "worker_version": null, "worker_run": null, - "image": "47f164d3-d26f-41e8-9df1-b538645d60b5", - "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", + "image": null, + "polygon": null, "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1094,18 +1113,18 @@ }, { "model": "documents.element", - "pk": "6c6a774e-88c3-40c5-9913-d7ec2a3d5125", + "pk": "8727b32e-6b08-44fd-84ff-54d505fb5a85", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 1, page 1r", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", + "name": "Surface B", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", + "image": "627a53a6-466c-4114-b826-852ecd328e47", + "polygon": "LINEARRING (600 600, 600 1000, 1000 1000, 1000 600, 600 600)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1113,12 +1132,12 @@ }, { "model": "documents.element", - "pk": "7a007e29-b509-4c4e-8321-78464df6f03a", + "pk": "900dcb10-2146-4571-8735-f508eb5f9986", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "4200ad41-d032-476a-acb4-943e6481ee6d", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "60e8c1f1-c04d-49b6-bc6e-70e38e437f36", "name": "Volume 2", "creator": null, "worker_version": null, @@ -1132,18 +1151,18 @@ }, { "model": "documents.element", - "pk": "86f68e65-f23b-4d01-a9ca-c40eed9cda32", + "pk": "9f1c4e10-d94c-4628-a575-5cb0afdd34af", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", - "name": "Act 4", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", + "name": "Surface A", "creator": null, "worker_version": null, "worker_run": null, - "image": null, - "polygon": null, + "image": "627a53a6-466c-4114-b826-852ecd328e47", + "polygon": "LINEARRING (0 0, 0 600, 600 600, 600 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1151,18 +1170,18 @@ }, { "model": "documents.element", - "pk": "92101c2b-885f-42e2-a0cb-13a8a04c0ea8", + "pk": "a0373c38-907d-4127-9762-5aada56b7165", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "PARIS", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", + "name": "Act 2", "creator": null, "worker_version": null, "worker_run": null, - "image": "47f164d3-d26f-41e8-9df1-b538645d60b5", - "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", + "image": null, + "polygon": null, "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1170,17 +1189,17 @@ }, { "model": "documents.element", - "pk": "9fd626f8-a5eb-4ae3-9f4f-f0ea4216712e", + "pk": "a41e2061-65cf-4c6e-9ea3-48d00d74133d", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", "name": "Surface C", "creator": null, "worker_version": null, "worker_run": null, - "image": "47f164d3-d26f-41e8-9df1-b538645d60b5", + "image": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, @@ -1189,18 +1208,18 @@ }, { "model": "documents.element", - "pk": "a8c108a2-a6b7-4401-9d64-65cf263531a7", + "pk": "ba400ee3-7247-4c20-bef4-3a49ad9e2e77", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "ROY", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "PARIS", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", + "image": "627a53a6-466c-4114-b826-852ecd328e47", + "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1208,17 +1227,17 @@ }, { "model": "documents.element", - "pk": "b0a0984b-dcf2-46da-8d6e-933020a46011", + "pk": "c80311e3-4529-45d6-9575-31b362c99925", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", "name": "DATUM", "creator": null, "worker_version": null, "worker_run": null, - "image": "47f164d3-d26f-41e8-9df1-b538645d60b5", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", "polygon": "LINEARRING (700 700, 700 800, 800 800, 800 700, 700 700)", "rotation_angle": 0, "mirrored": false, @@ -1227,18 +1246,18 @@ }, { "model": "documents.element", - "pk": "b8c4d1e3-aa9d-425a-b70f-c8ff16ae147c", + "pk": "dc79378e-1804-45c8-b23f-a2eeb33734de", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 2, page 2r", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", + "name": "Surface D", "creator": null, "worker_version": null, "worker_run": null, - "image": "4b93eabe-b42d-4b88-8f31-a63d0258000a", - "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (0 0, 0 300, 300 300, 300 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1246,18 +1265,18 @@ }, { "model": "documents.element", - "pk": "ba888ed5-8c85-4f49-8f87-499219187ade", + "pk": "e2e0ab90-85c8-457e-920d-479bebb43bf9", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", - "name": "Act 3", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "097f73b9-4ea7-4063-98b0-e17eb698e77a", + "name": "Text line", "creator": null, "worker_version": null, "worker_run": null, - "image": null, - "polygon": null, + "image": "627a53a6-466c-4114-b826-852ecd328e47", + "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1265,18 +1284,18 @@ }, { "model": "documents.element", - "pk": "c16df32e-03b5-47e4-868f-98d57eac1f15", + "pk": "e38ab791-092f-48eb-bb09-ea95e5a01998", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "7afe34f9-ad57-4ce7-872a-4d9aa31b6d0b", - "name": "Volume 2, page 1v", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "aa7b5e5a-eaa3-49b5-8c7a-826941c9d472", + "name": "ROY", "creator": null, "worker_version": null, "worker_run": null, - "image": "5de01fda-de62-4d26-b8bf-5c3f4678de3b", - "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", + "image": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", + "polygon": "LINEARRING (400 400, 400 500, 500 500, 500 400, 400 400)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1284,37 +1303,18 @@ }, { "model": "documents.element", - "pk": "d227268a-2c2e-40ff-b22c-ab6e52eb5bba", + "pk": "f09bebdb-152e-427c-ae2b-2e296982f342", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "eda3da9f-1a6b-4f7a-91d1-f28e4e74f76a", - "name": "Act 1", - "creator": null, - "worker_version": null, - "worker_run": null, - "image": null, - "polygon": null, - "rotation_angle": 0, - "mirrored": false, - "confidence": null - } -}, -{ - "model": "documents.element", - "pk": "d3dc3f63-9894-434b-9715-7bd906b63e63", - "fields": { - "created": "2020-02-02T01:23:45.678Z", - "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "3bbbd33a-b906-4526-9e0b-0643ade1b080", - "name": "PARIS", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "f2d75650-9d48-4b6e-b210-dbd595a668d2", + "name": "Volume 2, page 2r", "creator": null, "worker_version": null, "worker_run": null, - "image": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", - "polygon": "LINEARRING (100 100, 100 200, 200 200, 200 100, 100 100)", + "image": "d7f4c84a-8aae-4c7d-86ce-567c5d4adc6d", + "polygon": "LINEARRING (0 0, 0 1000, 1000 1000, 1000 0, 0 0)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1322,18 +1322,18 @@ }, { "model": "documents.element", - "pk": "d5daac1b-5f57-4d3c-b120-54adbcc82d7d", + "pk": "f6b8eced-ee41-42d3-812a-a622f1879809", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", - "name": "Surface D", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "fb9cf6d3-b31a-449c-a780-2f5db501695f", + "name": "Surface E", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", - "polygon": "LINEARRING (0 0, 0 300, 300 300, 300 0, 0 0)", + "image": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", + "polygon": "LINEARRING (300 300, 300 600, 600 600, 600 300, 300 300)", "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1341,18 +1341,18 @@ }, { "model": "documents.element", - "pk": "e2f8eee0-cf55-404c-86e6-6950ab88faac", + "pk": "f8d56f23-51f6-40dd-9482-939a1dd877b3", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "bc871e02-a23f-4799-a4d7-bb81813f41f5", - "name": "Surface F", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "c6267733-b1ce-4ea3-ac20-3a18c3d66f24", + "name": "Act 5", "creator": null, "worker_version": null, "worker_run": null, - "image": "d4db6162-7d7b-4839-a84f-756e74fef150", - "polygon": "LINEARRING (600 600, 600 1000, 1000 1000, 1000 600, 600 600)", + "image": null, + "polygon": null, "rotation_angle": 0, "mirrored": false, "confidence": null @@ -1360,91 +1360,91 @@ }, { "model": "documents.entitytype", - "pk": "4e1138f1-be4c-4205-834b-b2795e2bd1a3", + "pk": "4e9c9ca3-03ae-4994-b617-4d78f0854dc0", "fields": { - "name": "date", + "name": "person", "color": "ff0000", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337" } }, { "model": "documents.entitytype", - "pk": "564768cc-38be-45be-9e61-8dcade951c00", + "pk": "8c7ad642-50eb-4125-899b-aa3d03024a47", "fields": { "name": "location", "color": "ff0000", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337" } }, { "model": "documents.entitytype", - "pk": "a773549d-4adf-4dde-a595-3b2318f5cc0d", + "pk": "95fcaaf8-6a17-4540-8a76-866a6cad9f1d", "fields": { - "name": "person", + "name": "number", "color": "ff0000", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337" } }, { "model": "documents.entitytype", - "pk": "e19eb34b-55eb-4a8f-8019-18eea8e62fba", + "pk": "974f88d0-2e4c-4a9e-85b7-2e244c2e883f", "fields": { - "name": "number", + "name": "date", "color": "ff0000", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337" } }, { "model": "documents.entitytype", - "pk": "e96f8757-37d7-4457-9f2a-8e870cdadae5", + "pk": "e82c02b6-9b63-483a-a3c0-94fe032d83f8", "fields": { "name": "organization", "color": "ff0000", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337" } }, { "model": "documents.transcription", - "pk": "28e2570a-4022-4718-8827-5be712a53d4b", + "pk": "268de903-300e-4a00-8f10-e2e10ac2a733", "fields": { - "element": "3603393d-4831-4dbf-9672-3fbe1077e03a", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "60a6c062-1bbb-4e5f-82e7-37cb77d92419", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "ROY", + "text": "DATUM", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "626eaeaf-ae24-4bc0-b1f6-78ad744a3645", + "pk": "53d8a708-cd44-40b8-8862-7f663ece1133", "fields": { - "element": "6c6a774e-88c3-40c5-9913-d7ec2a3d5125", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "e38ab791-092f-48eb-bb09-ea95e5a01998", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "Lorem ipsum dolor sit amet", + "text": "ROY", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "62b7b9e6-0869-444c-aa8b-054fc124f6d9", + "pk": "56a95e2d-4262-42da-a435-a5f29c0bc094", "fields": { - "element": "a8c108a2-a6b7-4401-9d64-65cf263531a7", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "ba400ee3-7247-4c20-bef4-3a49ad9e2e77", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "ROY", + "text": "PARIS", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "6f477447-1b65-44f6-a912-6ff6b24165a2", + "pk": "7591f452-3d64-4b43-9789-0f05f2dfd6f8", "fields": { - "element": "2c0f28b1-b8e9-4f08-9014-75ce7950ee6f", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "6452b6c6-3d58-48c2-9032-691095fe8317", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, "text": "ROY", "orientation": "horizontal-lr", @@ -1453,111 +1453,111 @@ }, { "model": "documents.transcription", - "pk": "797bde96-8b76-4a9b-a4b9-b3378fcfdcdf", + "pk": "9b348778-35b7-4ed8-8232-4a641d51118d", "fields": { - "element": "18ada82e-36c6-47e4-bcff-bee5aa63889b", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "7f80cd7d-f24f-4691-a139-0ce744ea7577", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "DATUM", + "text": "PARIS", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "7b0f48b5-0580-47ef-9927-5e5fbdb749ff", + "pk": "dd193fa4-103c-43ae-8914-ed24f2487251", "fields": { - "element": "469f4606-f5ea-406d-a0bd-6d48050c39ed", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "7cd8a970-ca3a-479f-b49e-1292b216c1b9", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "PARIS", + "text": "DATUM", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "a94dabcd-9e37-4cbc-a45a-6e9a5a76b01a", + "pk": "de4d3e9b-597b-483a-8178-0c78328655bf", "fields": { - "element": "d3dc3f63-9894-434b-9715-7bd906b63e63", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "66843ee4-559b-4d2d-a9ce-4dd425022159", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "PARIS", + "text": "ROY", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "e74d7029-86e6-41ad-beed-12f4b215fb57", + "pk": "e37e143e-c6b5-4b3f-82ce-37e60fd188c1", "fields": { - "element": "34cdee1d-40e3-4499-b04b-93f6d2bfb802", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "1f20a3a4-33be-43a3-9427-393799aba6d7", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "DATUM", + "text": "PARIS", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "eae51f9a-eb40-4d27-946f-274231062d30", + "pk": "e41a7db5-5c44-4a14-88e9-92d7769263ca", "fields": { - "element": "92101c2b-885f-42e2-a0cb-13a8a04c0ea8", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "c80311e3-4529-45d6-9575-31b362c99925", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "PARIS", + "text": "DATUM", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.transcription", - "pk": "fb8d4786-a627-4049-9433-20755e7f3ca5", + "pk": "fe88b0e9-117e-4c9b-a761-a8e63f2f095a", "fields": { - "element": "b0a0984b-dcf2-46da-8d6e-933020a46011", - "worker_version": "70b6b804-b6f9-4efb-9d79-b8258c336eb7", + "element": "67482d70-765b-4092-b462-2d955fd8a7df", + "worker_version": "06e2171f-b7d6-49dc-90fc-d47784e6e334", "worker_run": null, - "text": "DATUM", + "text": "Lorem ipsum dolor sit amet", "orientation": "horizontal-lr", "confidence": 1.0 } }, { "model": "documents.allowedmetadata", - "pk": "7c15d98e-10f2-4c0b-809f-b65242d5e74b", + "pk": "13e9fa71-94a9-4e47-95a4-291597a811ef", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "date", - "name": "date" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "text", + "name": "folio" } }, { "model": "documents.allowedmetadata", - "pk": "819968c2-2433-4597-ac8b-852ed3397a9a", + "pk": "721a146d-a883-4f73-981a-1d90744d6d13", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "location", - "name": "location" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "date", + "name": "date" } }, { "model": "documents.allowedmetadata", - "pk": "c079f5d7-f039-43a5-93c6-9019e8203e6b", + "pk": "7b693888-219e-4f24-a930-de812973dc27", "fields": { - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", - "type": "text", - "name": "folio" + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", + "type": "location", + "name": "location" } }, { "model": "documents.metadata", - "pk": "03221610-3351-472d-9f7e-b23d7cbc7b92", + "pk": "0d46167f-853f-4a07-b780-216a8c400aa3", "fields": { - "element": "6a41ddf7-41e4-40ad-8234-d773b74058bc", - "name": "folio", + "element": "8349d4ba-fd23-4da6-a99b-76ba2e359b6f", + "name": "number", "type": "text", - "value": "1v", + "value": "3", "entity": null, "worker_version": null, "worker_run": null @@ -1565,12 +1565,12 @@ }, { "model": "documents.metadata", - "pk": "18459ddf-615f-47cc-8de6-11ab62446632", + "pk": "2cafc35d-ea3c-48cb-a93c-907d6f0a8643", "fields": { - "element": "664f19d6-472d-49cc-bd87-26c19db7d2d3", - "name": "number", + "element": "2aaa4788-127e-464c-bd8c-cd938796d207", + "name": "folio", "type": "text", - "value": "2", + "value": "1r", "entity": null, "worker_version": null, "worker_run": null @@ -1578,12 +1578,12 @@ }, { "model": "documents.metadata", - "pk": "34ec1306-1d88-4fc4-8049-d157252c5d51", + "pk": "435e31bc-7844-474b-aff4-c3b8fcc6927a", "fields": { - "element": "6c6a774e-88c3-40c5-9913-d7ec2a3d5125", - "name": "folio", + "element": "43ba23ed-3e59-466d-b48f-35928d3d9a59", + "name": "number", "type": "text", - "value": "1r", + "value": "1", "entity": null, "worker_version": null, "worker_run": null @@ -1591,12 +1591,12 @@ }, { "model": "documents.metadata", - "pk": "5712f834-778d-450b-ba46-86262c4a13f8", + "pk": "44548048-3180-4912-9134-10ae87037392", "fields": { - "element": "ba888ed5-8c85-4f49-8f87-499219187ade", - "name": "number", + "element": "737db4ff-8119-4e12-be99-968d7fa3be22", + "name": "folio", "type": "text", - "value": "3", + "value": "2r", "entity": null, "worker_version": null, "worker_run": null @@ -1604,9 +1604,9 @@ }, { "model": "documents.metadata", - "pk": "7d8d0dc1-fcf6-4e47-9b03-25d5baaac590", + "pk": "57fb874a-5aa8-4526-9cf8-ad5c10b38d24", "fields": { - "element": "86f68e65-f23b-4d01-a9ca-c40eed9cda32", + "element": "8144a0f8-f75b-43fa-ac27-d49d69bc483c", "name": "number", "type": "text", "value": "4", @@ -1617,12 +1617,12 @@ }, { "model": "documents.metadata", - "pk": "820d9d2f-c6d0-40df-9bce-cb9062f17b0b", + "pk": "9ea4ce55-d95c-46d2-802e-89f98abe862b", "fields": { - "element": "60313262-3273-4c39-a8ee-cedc733f6990", + "element": "f09bebdb-152e-427c-ae2b-2e296982f342", "name": "folio", "type": "text", - "value": "1r", + "value": "2r", "entity": null, "worker_version": null, "worker_run": null @@ -1630,12 +1630,12 @@ }, { "model": "documents.metadata", - "pk": "93e2a919-9320-47ce-9b0f-de178dab40bc", + "pk": "b22d936a-386d-48e3-88e0-6b88da42c982", "fields": { - "element": "3f3bb3ee-ec7c-436c-9dde-f8aa0e1a69d7", - "name": "number", + "element": "492ea0b0-bcaf-4a46-a15a-29cd08e32d90", + "name": "folio", "type": "text", - "value": "5", + "value": "1v", "entity": null, "worker_version": null, "worker_run": null @@ -1643,12 +1643,12 @@ }, { "model": "documents.metadata", - "pk": "9fc32596-bd62-4f8d-894d-abfeefc6157a", + "pk": "d58788f8-58a9-488a-bef9-0302705ff942", "fields": { - "element": "4d2f59fe-9808-44b1-b3b5-017490ae4064", - "name": "folio", + "element": "f8d56f23-51f6-40dd-9482-939a1dd877b3", + "name": "number", "type": "text", - "value": "2r", + "value": "5", "entity": null, "worker_version": null, "worker_run": null @@ -1656,12 +1656,12 @@ }, { "model": "documents.metadata", - "pk": "ba9916fa-52e6-4203-8db4-662f9083c70c", + "pk": "de810d60-9a4b-42e3-85c4-7a2174a01213", "fields": { - "element": "c16df32e-03b5-47e4-868f-98d57eac1f15", - "name": "folio", + "element": "a0373c38-907d-4127-9762-5aada56b7165", + "name": "number", "type": "text", - "value": "1v", + "value": "2", "entity": null, "worker_version": null, "worker_run": null @@ -1669,12 +1669,12 @@ }, { "model": "documents.metadata", - "pk": "bf7933a6-1646-40d0-be66-1306bc564481", + "pk": "e4b071a7-41f5-416f-aeee-53ca55d4b640", "fields": { - "element": "b8c4d1e3-aa9d-425a-b70f-c8ff16ae147c", + "element": "67482d70-765b-4092-b462-2d955fd8a7df", "name": "folio", "type": "text", - "value": "2r", + "value": "1r", "entity": null, "worker_version": null, "worker_run": null @@ -1682,12 +1682,12 @@ }, { "model": "documents.metadata", - "pk": "cffe6d09-810c-4365-8107-af240d60097e", + "pk": "fec9c649-5fd4-43f7-ab28-3b364b868087", "fields": { - "element": "d227268a-2c2e-40ff-b22c-ab6e52eb5bba", - "name": "number", + "element": "7ac6504c-4088-4e6e-9a48-567f1f7e54c8", + "name": "folio", "type": "text", - "value": "1", + "value": "1v", "entity": null, "worker_version": null, "worker_run": null @@ -1710,12 +1710,12 @@ }, { "model": "images.image", - "pk": "2552f45d-6880-4a4c-b9c2-1091598a52b4", + "pk": "4867c2a1-fadc-4086-842c-c914b678d37d", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img4", + "path": "img5", "width": 1000, "height": 1000, "hash": null, @@ -1724,12 +1724,12 @@ }, { "model": "images.image", - "pk": "47f164d3-d26f-41e8-9df1-b538645d60b5", + "pk": "627a53a6-466c-4114-b826-852ecd328e47", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img2", + "path": "img1", "width": 1000, "height": 1000, "hash": null, @@ -1738,12 +1738,12 @@ }, { "model": "images.image", - "pk": "4b93eabe-b42d-4b88-8f31-a63d0258000a", + "pk": "68df44c1-ed1f-4f29-870d-de7b4b03b1a5", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img6", + "path": "img3", "width": 1000, "height": 1000, "hash": null, @@ -1752,12 +1752,12 @@ }, { "model": "images.image", - "pk": "5de01fda-de62-4d26-b8bf-5c3f4678de3b", + "pk": "7f006318-b3d2-4cda-b1ab-8ff844c07b35", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img5", + "path": "img2", "width": 1000, "height": 1000, "hash": null, @@ -1766,12 +1766,12 @@ }, { "model": "images.image", - "pk": "d3547a48-79f6-4c1f-8f16-c0be1a1da9fc", + "pk": "a68c21d7-d24f-44d7-b134-3c50ee6ddba1", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img1", + "path": "img4", "width": 1000, "height": 1000, "hash": null, @@ -1780,12 +1780,12 @@ }, { "model": "images.image", - "pk": "d4db6162-7d7b-4839-a84f-756e74fef150", + "pk": "d7f4c84a-8aae-4c7d-86ce-567c5d4adc6d", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "server": 1, - "path": "img3", + "path": "img6", "width": 1000, "height": 1000, "hash": null, @@ -1794,56 +1794,56 @@ }, { "model": "users.right", - "pk": "066494da-9d28-44a8-ad04-0616cde80af0", + "pk": "053efd1b-d12f-49e5-ae72-54c7008224ec", "fields": { "user": 2, "group": null, - "content_type": 20, - "content_id": "fdbe4b5c-9475-4310-8877-eb07344e294a", + "content_type": 19, + "content_id": "1204ee59-2b68-4dfb-a083-3df4996c2337", "level": 100 } }, { "model": "users.right", - "pk": "58ce1f20-32a1-4507-8edd-28fff7a1a77f", + "pk": "3e267701-586a-45f5-adf3-20bab26fbaae", "fields": { "user": 2, "group": null, - "content_type": 12, - "content_id": "c19116b6-866f-4bb7-b447-3544629a8151", - "level": 10 + "content_type": 34, + "content_id": "192deb9f-d37e-4edd-bb9b-2ae35c7ecba5", + "level": 100 } }, { "model": "users.right", - "pk": "9f94b10b-ffd6-4a87-885d-af494d41c850", + "pk": "7c7634a0-2cd6-4d54-9a1b-14b0c0390050", "fields": { - "user": 3, + "user": 2, "group": null, - "content_type": 35, - "content_id": "c104ee5f-be60-4cdd-a738-10bf24601682", - "level": 50 + "content_type": 11, + "content_id": "94055f35-9ac9-44a0-ac5d-386e132bea2c", + "level": 10 } }, { "model": "users.right", - "pk": "b04e5fb5-dbca-4255-aae1-4c100c510c9d", + "pk": "9f79af1d-f49f-4fd7-8504-2553ceab64db", "fields": { - "user": 2, + "user": 3, "group": null, - "content_type": 35, - "content_id": "c104ee5f-be60-4cdd-a738-10bf24601682", - "level": 100 + "content_type": 34, + "content_id": "192deb9f-d37e-4edd-bb9b-2ae35c7ecba5", + "level": 50 } }, { "model": "users.right", - "pk": "da8297fb-5484-4511-8d29-0e8d6cbccc49", + "pk": "f38dad9a-d7b5-43cc-8ec7-e437296a0ca4", "fields": { "user": 4, "group": null, - "content_type": 35, - "content_id": "c104ee5f-be60-4cdd-a738-10bf24601682", + "content_type": 34, + "content_id": "192deb9f-d37e-4edd-bb9b-2ae35c7ecba5", "level": 10 } }, @@ -1851,7 +1851,7 @@ "model": "users.user", "pk": 1, "fields": { - "password": "pbkdf2_sha256$390000$IL4asfhs96nzQKK9md5Axl$uXyWwT/if+W9OsJ+OeHkPZWm4xlQ7D1ep2NyjxSNOLs=", + "password": "pbkdf2_sha256$390000$uNaUvBneqUWhzAsyWIWTw0$2Q13vkqIe5EhzX9rxV94tnAAuzYxXL1OMkfD49k8j0U=", "last_login": null, "email": "root@root.fr", "display_name": "Admin", @@ -1866,7 +1866,7 @@ "model": "users.user", "pk": 2, "fields": { - "password": "pbkdf2_sha256$390000$RvCTxXTmAXRgZ29LJrofXG$vnWF7puXQBP+G8wcclsOrd2ZpHbiq7jC/kX7F31tSQQ=", + "password": "pbkdf2_sha256$390000$c0a4pOThTZIgnvLuFhk0M8$uY0aZIWY33541EueCEsJwiSn9JV4/xMmblh2ZB+iJIQ=", "last_login": null, "email": "user@user.fr", "display_name": "Test user", @@ -1909,7 +1909,7 @@ }, { "model": "users.group", - "pk": "c104ee5f-be60-4cdd-a738-10bf24601682", + "pk": "192deb9f-d37e-4edd-bb9b-2ae35c7ecba5", "fields": { "name": "User group", "public": false, @@ -1921,7 +1921,7 @@ "pk": 1, "fields": { "name": "Can add log entry", - "content_type": 3, + "content_type": 1, "codename": "add_logentry" } }, @@ -1930,7 +1930,7 @@ "pk": 2, "fields": { "name": "Can change log entry", - "content_type": 3, + "content_type": 1, "codename": "change_logentry" } }, @@ -1939,7 +1939,7 @@ "pk": 3, "fields": { "name": "Can delete log entry", - "content_type": 3, + "content_type": 1, "codename": "delete_logentry" } }, @@ -1948,7 +1948,7 @@ "pk": 4, "fields": { "name": "Can view log entry", - "content_type": 3, + "content_type": 1, "codename": "view_logentry" } }, @@ -1957,7 +1957,7 @@ "pk": 5, "fields": { "name": "Can add permission", - "content_type": 4, + "content_type": 2, "codename": "add_permission" } }, @@ -1966,7 +1966,7 @@ "pk": 6, "fields": { "name": "Can change permission", - "content_type": 4, + "content_type": 2, "codename": "change_permission" } }, @@ -1975,7 +1975,7 @@ "pk": 7, "fields": { "name": "Can delete permission", - "content_type": 4, + "content_type": 2, "codename": "delete_permission" } }, @@ -1984,7 +1984,7 @@ "pk": 8, "fields": { "name": "Can view permission", - "content_type": 4, + "content_type": 2, "codename": "view_permission" } }, @@ -1993,7 +1993,7 @@ "pk": 9, "fields": { "name": "Can add group", - "content_type": 5, + "content_type": 3, "codename": "add_group" } }, @@ -2002,7 +2002,7 @@ "pk": 10, "fields": { "name": "Can change group", - "content_type": 5, + "content_type": 3, "codename": "change_group" } }, @@ -2011,7 +2011,7 @@ "pk": 11, "fields": { "name": "Can delete group", - "content_type": 5, + "content_type": 3, "codename": "delete_group" } }, @@ -2020,7 +2020,7 @@ "pk": 12, "fields": { "name": "Can view group", - "content_type": 5, + "content_type": 3, "codename": "view_group" } }, @@ -2029,7 +2029,7 @@ "pk": 13, "fields": { "name": "Can add content type", - "content_type": 6, + "content_type": 4, "codename": "add_contenttype" } }, @@ -2038,7 +2038,7 @@ "pk": 14, "fields": { "name": "Can change content type", - "content_type": 6, + "content_type": 4, "codename": "change_contenttype" } }, @@ -2047,7 +2047,7 @@ "pk": 15, "fields": { "name": "Can delete content type", - "content_type": 6, + "content_type": 4, "codename": "delete_contenttype" } }, @@ -2056,7 +2056,7 @@ "pk": 16, "fields": { "name": "Can view content type", - "content_type": 6, + "content_type": 4, "codename": "view_contenttype" } }, @@ -2065,7 +2065,7 @@ "pk": 17, "fields": { "name": "Can add session", - "content_type": 7, + "content_type": 5, "codename": "add_session" } }, @@ -2074,7 +2074,7 @@ "pk": 18, "fields": { "name": "Can change session", - "content_type": 7, + "content_type": 5, "codename": "change_session" } }, @@ -2083,7 +2083,7 @@ "pk": 19, "fields": { "name": "Can delete session", - "content_type": 7, + "content_type": 5, "codename": "delete_session" } }, @@ -2092,7 +2092,7 @@ "pk": 20, "fields": { "name": "Can view session", - "content_type": 7, + "content_type": 5, "codename": "view_session" } }, @@ -2101,7 +2101,7 @@ "pk": 21, "fields": { "name": "Can add Token", - "content_type": 8, + "content_type": 6, "codename": "add_token" } }, @@ -2110,7 +2110,7 @@ "pk": 22, "fields": { "name": "Can change Token", - "content_type": 8, + "content_type": 6, "codename": "change_token" } }, @@ -2119,7 +2119,7 @@ "pk": 23, "fields": { "name": "Can delete Token", - "content_type": 8, + "content_type": 6, "codename": "delete_token" } }, @@ -2128,7 +2128,7 @@ "pk": 24, "fields": { "name": "Can view Token", - "content_type": 8, + "content_type": 6, "codename": "view_token" } }, @@ -2137,7 +2137,7 @@ "pk": 25, "fields": { "name": "Can add token", - "content_type": 9, + "content_type": 7, "codename": "add_tokenproxy" } }, @@ -2146,7 +2146,7 @@ "pk": 26, "fields": { "name": "Can change token", - "content_type": 9, + "content_type": 7, "codename": "change_tokenproxy" } }, @@ -2155,7 +2155,7 @@ "pk": 27, "fields": { "name": "Can delete token", - "content_type": 9, + "content_type": 7, "codename": "delete_tokenproxy" } }, @@ -2164,7 +2164,7 @@ "pk": 28, "fields": { "name": "Can view token", - "content_type": 9, + "content_type": 7, "codename": "view_tokenproxy" } }, @@ -2173,7 +2173,7 @@ "pk": 29, "fields": { "name": "Access admin page", - "content_type": 10, + "content_type": 8, "codename": "view" } }, @@ -2182,7 +2182,7 @@ "pk": 30, "fields": { "name": "Can add agent", - "content_type": 1, + "content_type": 9, "codename": "add_agent" } }, @@ -2191,7 +2191,7 @@ "pk": 31, "fields": { "name": "Can change agent", - "content_type": 1, + "content_type": 9, "codename": "change_agent" } }, @@ -2200,7 +2200,7 @@ "pk": 32, "fields": { "name": "Can delete agent", - "content_type": 1, + "content_type": 9, "codename": "delete_agent" } }, @@ -2209,7 +2209,7 @@ "pk": 33, "fields": { "name": "Can view agent", - "content_type": 1, + "content_type": 9, "codename": "view_agent" } }, @@ -2218,7 +2218,7 @@ "pk": 34, "fields": { "name": "Can add artifact", - "content_type": 11, + "content_type": 10, "codename": "add_artifact" } }, @@ -2227,7 +2227,7 @@ "pk": 35, "fields": { "name": "Can change artifact", - "content_type": 11, + "content_type": 10, "codename": "change_artifact" } }, @@ -2236,7 +2236,7 @@ "pk": 36, "fields": { "name": "Can delete artifact", - "content_type": 11, + "content_type": 10, "codename": "delete_artifact" } }, @@ -2245,7 +2245,7 @@ "pk": 37, "fields": { "name": "Can view artifact", - "content_type": 11, + "content_type": 10, "codename": "view_artifact" } }, @@ -2254,7 +2254,7 @@ "pk": 38, "fields": { "name": "Can add farm", - "content_type": 12, + "content_type": 11, "codename": "add_farm" } }, @@ -2263,7 +2263,7 @@ "pk": 39, "fields": { "name": "Can change farm", - "content_type": 12, + "content_type": 11, "codename": "change_farm" } }, @@ -2272,7 +2272,7 @@ "pk": 40, "fields": { "name": "Can delete farm", - "content_type": 12, + "content_type": 11, "codename": "delete_farm" } }, @@ -2281,7 +2281,7 @@ "pk": 41, "fields": { "name": "Can view farm", - "content_type": 12, + "content_type": 11, "codename": "view_farm" } }, @@ -2290,7 +2290,7 @@ "pk": 42, "fields": { "name": "Can add gpu", - "content_type": 13, + "content_type": 12, "codename": "add_gpu" } }, @@ -2299,7 +2299,7 @@ "pk": 43, "fields": { "name": "Can change gpu", - "content_type": 13, + "content_type": 12, "codename": "change_gpu" } }, @@ -2308,7 +2308,7 @@ "pk": 44, "fields": { "name": "Can delete gpu", - "content_type": 13, + "content_type": 12, "codename": "delete_gpu" } }, @@ -2317,7 +2317,7 @@ "pk": 45, "fields": { "name": "Can view gpu", - "content_type": 13, + "content_type": 12, "codename": "view_gpu" } }, @@ -2326,7 +2326,7 @@ "pk": 46, "fields": { "name": "Can add secret", - "content_type": 14, + "content_type": 13, "codename": "add_secret" } }, @@ -2335,7 +2335,7 @@ "pk": 47, "fields": { "name": "Can change secret", - "content_type": 14, + "content_type": 13, "codename": "change_secret" } }, @@ -2344,7 +2344,7 @@ "pk": 48, "fields": { "name": "Can delete secret", - "content_type": 14, + "content_type": 13, "codename": "delete_secret" } }, @@ -2353,7 +2353,7 @@ "pk": 49, "fields": { "name": "Can view secret", - "content_type": 14, + "content_type": 13, "codename": "view_secret" } }, @@ -2362,7 +2362,7 @@ "pk": 50, "fields": { "name": "Can add task", - "content_type": 15, + "content_type": 14, "codename": "add_task" } }, @@ -2371,7 +2371,7 @@ "pk": 51, "fields": { "name": "Can change task", - "content_type": 15, + "content_type": 14, "codename": "change_task" } }, @@ -2380,7 +2380,7 @@ "pk": 52, "fields": { "name": "Can delete task", - "content_type": 15, + "content_type": 14, "codename": "delete_task" } }, @@ -2389,7 +2389,7 @@ "pk": 53, "fields": { "name": "Can view task", - "content_type": 15, + "content_type": 14, "codename": "view_task" } }, @@ -2397,1561 +2397,1525 @@ "model": "auth.permission", "pk": 54, "fields": { - "name": "Can add agent user", - "content_type": 2, - "codename": "add_agentuser" + "name": "Can add image", + "content_type": 15, + "codename": "add_image" } }, { "model": "auth.permission", "pk": 55, "fields": { - "name": "Can change agent user", - "content_type": 2, - "codename": "change_agentuser" + "name": "Can change image", + "content_type": 15, + "codename": "change_image" } }, { "model": "auth.permission", "pk": 56, "fields": { - "name": "Can delete agent user", - "content_type": 2, - "codename": "delete_agentuser" + "name": "Can delete image", + "content_type": 15, + "codename": "delete_image" } }, { "model": "auth.permission", "pk": 57, "fields": { - "name": "Can view agent user", - "content_type": 2, - "codename": "view_agentuser" + "name": "Can view image", + "content_type": 15, + "codename": "view_image" } }, { "model": "auth.permission", "pk": 58, "fields": { - "name": "Can add image", + "name": "Can add image server", "content_type": 16, - "codename": "add_image" - } -}, -{ - "model": "auth.permission", - "pk": 59, - "fields": { - "name": "Can change image", - "content_type": 16, - "codename": "change_image" - } -}, -{ - "model": "auth.permission", - "pk": 60, - "fields": { - "name": "Can delete image", - "content_type": 16, - "codename": "delete_image" - } -}, -{ - "model": "auth.permission", - "pk": 61, - "fields": { - "name": "Can view image", - "content_type": 16, - "codename": "view_image" - } -}, -{ - "model": "auth.permission", - "pk": 62, - "fields": { - "name": "Can add image server", - "content_type": 17, "codename": "add_imageserver" } }, { "model": "auth.permission", - "pk": 63, + "pk": 59, "fields": { "name": "Can change image server", - "content_type": 17, + "content_type": 16, "codename": "change_imageserver" } }, { "model": "auth.permission", - "pk": 64, + "pk": 60, "fields": { "name": "Can delete image server", - "content_type": 17, + "content_type": 16, "codename": "delete_imageserver" } }, { "model": "auth.permission", - "pk": 65, + "pk": 61, "fields": { "name": "Can view image server", - "content_type": 17, + "content_type": 16, "codename": "view_imageserver" } }, { "model": "auth.permission", - "pk": 66, + "pk": 62, "fields": { "name": "Can add allowed meta data", - "content_type": 18, + "content_type": 17, "codename": "add_allowedmetadata" } }, { "model": "auth.permission", - "pk": 67, + "pk": 63, "fields": { "name": "Can change allowed meta data", - "content_type": 18, + "content_type": 17, "codename": "change_allowedmetadata" } }, { "model": "auth.permission", - "pk": 68, + "pk": 64, "fields": { "name": "Can delete allowed meta data", - "content_type": 18, + "content_type": 17, "codename": "delete_allowedmetadata" } }, { "model": "auth.permission", - "pk": 69, + "pk": 65, "fields": { "name": "Can view allowed meta data", - "content_type": 18, + "content_type": 17, "codename": "view_allowedmetadata" } }, { "model": "auth.permission", - "pk": 70, + "pk": 66, "fields": { "name": "Can add classification", - "content_type": 19, + "content_type": 18, "codename": "add_classification" } }, { "model": "auth.permission", - "pk": 71, + "pk": 67, "fields": { "name": "Can change classification", - "content_type": 19, + "content_type": 18, "codename": "change_classification" } }, { "model": "auth.permission", - "pk": 72, + "pk": 68, "fields": { "name": "Can delete classification", - "content_type": 19, + "content_type": 18, "codename": "delete_classification" } }, { "model": "auth.permission", - "pk": 73, + "pk": 69, "fields": { "name": "Can view classification", - "content_type": 19, + "content_type": 18, "codename": "view_classification" } }, { "model": "auth.permission", - "pk": 74, + "pk": 70, "fields": { "name": "Can add corpus", - "content_type": 20, + "content_type": 19, "codename": "add_corpus" } }, { "model": "auth.permission", - "pk": 75, + "pk": 71, "fields": { "name": "Can change corpus", - "content_type": 20, + "content_type": 19, "codename": "change_corpus" } }, { "model": "auth.permission", - "pk": 76, + "pk": 72, "fields": { "name": "Can delete corpus", - "content_type": 20, + "content_type": 19, "codename": "delete_corpus" } }, { "model": "auth.permission", - "pk": 77, + "pk": 73, "fields": { "name": "Can view corpus", - "content_type": 20, + "content_type": 19, "codename": "view_corpus" } }, { "model": "auth.permission", - "pk": 78, + "pk": 74, "fields": { "name": "Can add corpus export", - "content_type": 21, + "content_type": 20, "codename": "add_corpusexport" } }, { "model": "auth.permission", - "pk": 79, + "pk": 75, "fields": { "name": "Can change corpus export", - "content_type": 21, + "content_type": 20, "codename": "change_corpusexport" } }, { "model": "auth.permission", - "pk": 80, + "pk": 76, "fields": { "name": "Can delete corpus export", - "content_type": 21, + "content_type": 20, "codename": "delete_corpusexport" } }, { "model": "auth.permission", - "pk": 81, + "pk": 77, "fields": { "name": "Can view corpus export", - "content_type": 21, + "content_type": 20, "codename": "view_corpusexport" } }, { "model": "auth.permission", - "pk": 82, + "pk": 78, "fields": { "name": "Can add element", - "content_type": 22, + "content_type": 21, "codename": "add_element" } }, { "model": "auth.permission", - "pk": 83, + "pk": 79, "fields": { "name": "Can change element", - "content_type": 22, + "content_type": 21, "codename": "change_element" } }, { "model": "auth.permission", - "pk": 84, + "pk": 80, "fields": { "name": "Can delete element", - "content_type": 22, + "content_type": 21, "codename": "delete_element" } }, { "model": "auth.permission", - "pk": 85, + "pk": 81, "fields": { "name": "Can view element", - "content_type": 22, + "content_type": 21, "codename": "view_element" } }, { "model": "auth.permission", - "pk": 86, + "pk": 82, "fields": { "name": "Can add element path", - "content_type": 23, + "content_type": 22, "codename": "add_elementpath" } }, { "model": "auth.permission", - "pk": 87, + "pk": 83, "fields": { "name": "Can change element path", - "content_type": 23, + "content_type": 22, "codename": "change_elementpath" } }, { "model": "auth.permission", - "pk": 88, + "pk": 84, "fields": { "name": "Can delete element path", - "content_type": 23, + "content_type": 22, "codename": "delete_elementpath" } }, { "model": "auth.permission", - "pk": 89, + "pk": 85, "fields": { "name": "Can view element path", - "content_type": 23, + "content_type": 22, "codename": "view_elementpath" } }, { "model": "auth.permission", - "pk": 90, + "pk": 86, "fields": { "name": "Can add element type", - "content_type": 24, + "content_type": 23, "codename": "add_elementtype" } }, { "model": "auth.permission", - "pk": 91, + "pk": 87, "fields": { "name": "Can change element type", - "content_type": 24, + "content_type": 23, "codename": "change_elementtype" } }, { "model": "auth.permission", - "pk": 92, + "pk": 88, "fields": { "name": "Can delete element type", - "content_type": 24, + "content_type": 23, "codename": "delete_elementtype" } }, { "model": "auth.permission", - "pk": 93, + "pk": 89, "fields": { "name": "Can view element type", - "content_type": 24, + "content_type": 23, "codename": "view_elementtype" } }, { "model": "auth.permission", - "pk": 94, + "pk": 90, "fields": { "name": "Can add entity", - "content_type": 25, + "content_type": 24, "codename": "add_entity" } }, { "model": "auth.permission", - "pk": 95, + "pk": 91, "fields": { "name": "Can change entity", - "content_type": 25, + "content_type": 24, "codename": "change_entity" } }, { "model": "auth.permission", - "pk": 96, + "pk": 92, "fields": { "name": "Can delete entity", - "content_type": 25, + "content_type": 24, "codename": "delete_entity" } }, { "model": "auth.permission", - "pk": 97, + "pk": 93, "fields": { "name": "Can view entity", - "content_type": 25, + "content_type": 24, "codename": "view_entity" } }, { "model": "auth.permission", - "pk": 98, + "pk": 94, "fields": { "name": "Can add entity link", - "content_type": 26, + "content_type": 25, "codename": "add_entitylink" } }, { "model": "auth.permission", - "pk": 99, + "pk": 95, "fields": { "name": "Can change entity link", - "content_type": 26, + "content_type": 25, "codename": "change_entitylink" } }, { "model": "auth.permission", - "pk": 100, + "pk": 96, "fields": { "name": "Can delete entity link", - "content_type": 26, + "content_type": 25, "codename": "delete_entitylink" } }, { "model": "auth.permission", - "pk": 101, + "pk": 97, "fields": { "name": "Can view entity link", - "content_type": 26, + "content_type": 25, "codename": "view_entitylink" } }, { "model": "auth.permission", - "pk": 102, + "pk": 98, "fields": { "name": "Can add entity role", - "content_type": 27, + "content_type": 26, "codename": "add_entityrole" } }, { "model": "auth.permission", - "pk": 103, + "pk": 99, "fields": { "name": "Can change entity role", - "content_type": 27, + "content_type": 26, "codename": "change_entityrole" } }, { "model": "auth.permission", - "pk": 104, + "pk": 100, "fields": { "name": "Can delete entity role", - "content_type": 27, + "content_type": 26, "codename": "delete_entityrole" } }, { "model": "auth.permission", - "pk": 105, + "pk": 101, "fields": { "name": "Can view entity role", - "content_type": 27, + "content_type": 26, "codename": "view_entityrole" } }, { "model": "auth.permission", - "pk": 106, + "pk": 102, "fields": { "name": "Can add entity type", - "content_type": 28, + "content_type": 27, "codename": "add_entitytype" } }, { "model": "auth.permission", - "pk": 107, + "pk": 103, "fields": { "name": "Can change entity type", - "content_type": 28, + "content_type": 27, "codename": "change_entitytype" } }, { "model": "auth.permission", - "pk": 108, + "pk": 104, "fields": { "name": "Can delete entity type", - "content_type": 28, + "content_type": 27, "codename": "delete_entitytype" } }, { "model": "auth.permission", - "pk": 109, + "pk": 105, "fields": { "name": "Can view entity type", - "content_type": 28, + "content_type": 27, "codename": "view_entitytype" } }, { "model": "auth.permission", - "pk": 110, + "pk": 106, "fields": { "name": "Can add meta data", - "content_type": 29, + "content_type": 28, "codename": "add_metadata" } }, { "model": "auth.permission", - "pk": 111, + "pk": 107, "fields": { "name": "Can change meta data", - "content_type": 29, + "content_type": 28, "codename": "change_metadata" } }, { "model": "auth.permission", - "pk": 112, + "pk": 108, "fields": { "name": "Can delete meta data", - "content_type": 29, + "content_type": 28, "codename": "delete_metadata" } }, { "model": "auth.permission", - "pk": 113, + "pk": 109, "fields": { "name": "Can view meta data", - "content_type": 29, + "content_type": 28, "codename": "view_metadata" } }, { "model": "auth.permission", - "pk": 114, + "pk": 110, "fields": { "name": "Can add ml class", - "content_type": 30, + "content_type": 29, "codename": "add_mlclass" } }, { "model": "auth.permission", - "pk": 115, + "pk": 111, "fields": { "name": "Can change ml class", - "content_type": 30, + "content_type": 29, "codename": "change_mlclass" } }, { "model": "auth.permission", - "pk": 116, + "pk": 112, "fields": { "name": "Can delete ml class", - "content_type": 30, + "content_type": 29, "codename": "delete_mlclass" } }, { "model": "auth.permission", - "pk": 117, + "pk": 113, "fields": { "name": "Can view ml class", - "content_type": 30, + "content_type": 29, "codename": "view_mlclass" } }, { "model": "auth.permission", - "pk": 118, + "pk": 114, "fields": { "name": "Can add selection", - "content_type": 31, + "content_type": 30, "codename": "add_selection" } }, { "model": "auth.permission", - "pk": 119, + "pk": 115, "fields": { "name": "Can change selection", - "content_type": 31, + "content_type": 30, "codename": "change_selection" } }, { "model": "auth.permission", - "pk": 120, + "pk": 116, "fields": { "name": "Can delete selection", - "content_type": 31, + "content_type": 30, "codename": "delete_selection" } }, { "model": "auth.permission", - "pk": 121, + "pk": 117, "fields": { "name": "Can view selection", - "content_type": 31, + "content_type": 30, "codename": "view_selection" } }, { "model": "auth.permission", - "pk": 122, + "pk": 118, "fields": { "name": "Can add transcription", - "content_type": 32, + "content_type": 31, "codename": "add_transcription" } }, { "model": "auth.permission", - "pk": 123, + "pk": 119, "fields": { "name": "Can change transcription", - "content_type": 32, + "content_type": 31, "codename": "change_transcription" } }, { "model": "auth.permission", - "pk": 124, + "pk": 120, "fields": { "name": "Can delete transcription", - "content_type": 32, + "content_type": 31, "codename": "delete_transcription" } }, { "model": "auth.permission", - "pk": 125, + "pk": 121, "fields": { "name": "Can view transcription", - "content_type": 32, + "content_type": 31, "codename": "view_transcription" } }, { "model": "auth.permission", - "pk": 126, + "pk": 122, "fields": { "name": "Can add transcription entity", - "content_type": 33, + "content_type": 32, "codename": "add_transcriptionentity" } }, { "model": "auth.permission", - "pk": 127, + "pk": 123, "fields": { "name": "Can change transcription entity", - "content_type": 33, + "content_type": 32, "codename": "change_transcriptionentity" } }, { "model": "auth.permission", - "pk": 128, + "pk": 124, "fields": { "name": "Can delete transcription entity", - "content_type": 33, + "content_type": 32, "codename": "delete_transcriptionentity" } }, { "model": "auth.permission", - "pk": 129, + "pk": 125, "fields": { "name": "Can view transcription entity", - "content_type": 33, + "content_type": 32, "codename": "view_transcriptionentity" } }, { "model": "auth.permission", - "pk": 130, + "pk": 126, "fields": { "name": "Can add user", - "content_type": 34, + "content_type": 33, "codename": "add_user" } }, { "model": "auth.permission", - "pk": 131, + "pk": 127, "fields": { "name": "Can change user", - "content_type": 34, + "content_type": 33, "codename": "change_user" } }, { "model": "auth.permission", - "pk": 132, + "pk": 128, "fields": { "name": "Can delete user", - "content_type": 34, + "content_type": 33, "codename": "delete_user" } }, { "model": "auth.permission", - "pk": 133, + "pk": 129, "fields": { "name": "Can view user", - "content_type": 34, + "content_type": 33, "codename": "view_user" } }, { "model": "auth.permission", - "pk": 134, + "pk": 130, "fields": { "name": "Can add group", - "content_type": 35, + "content_type": 34, "codename": "add_group" } }, { "model": "auth.permission", - "pk": 135, + "pk": 131, "fields": { "name": "Can change group", - "content_type": 35, + "content_type": 34, "codename": "change_group" } }, { "model": "auth.permission", - "pk": 136, + "pk": 132, "fields": { "name": "Can delete group", - "content_type": 35, + "content_type": 34, "codename": "delete_group" } }, { "model": "auth.permission", - "pk": 137, + "pk": 133, "fields": { "name": "Can view group", - "content_type": 35, + "content_type": 34, "codename": "view_group" } }, { "model": "auth.permission", - "pk": 138, + "pk": 134, "fields": { "name": "Can add right", - "content_type": 36, + "content_type": 35, "codename": "add_right" } }, { "model": "auth.permission", - "pk": 139, + "pk": 135, "fields": { "name": "Can change right", - "content_type": 36, + "content_type": 35, "codename": "change_right" } }, { "model": "auth.permission", - "pk": 140, + "pk": 136, "fields": { "name": "Can delete right", - "content_type": 36, + "content_type": 35, "codename": "delete_right" } }, { "model": "auth.permission", - "pk": 141, + "pk": 137, "fields": { "name": "Can view right", - "content_type": 36, + "content_type": 35, "codename": "view_right" } }, { "model": "auth.permission", - "pk": 142, + "pk": 138, "fields": { "name": "Can add user scope", - "content_type": 37, + "content_type": 36, "codename": "add_userscope" } }, { "model": "auth.permission", - "pk": 143, + "pk": 139, "fields": { "name": "Can change user scope", - "content_type": 37, + "content_type": 36, "codename": "change_userscope" } }, { "model": "auth.permission", - "pk": 144, + "pk": 140, "fields": { "name": "Can delete user scope", - "content_type": 37, + "content_type": 36, "codename": "delete_userscope" } }, { "model": "auth.permission", - "pk": 145, + "pk": 141, "fields": { "name": "Can view user scope", - "content_type": 37, + "content_type": 36, "codename": "view_userscope" } }, { "model": "auth.permission", - "pk": 146, + "pk": 142, "fields": { "name": "Can add corpus worker version", - "content_type": 38, + "content_type": 37, "codename": "add_corpusworkerversion" } }, { "model": "auth.permission", - "pk": 147, + "pk": 143, "fields": { "name": "Can change corpus worker version", - "content_type": 38, + "content_type": 37, "codename": "change_corpusworkerversion" } }, { "model": "auth.permission", - "pk": 148, + "pk": 144, "fields": { "name": "Can delete corpus worker version", - "content_type": 38, + "content_type": 37, "codename": "delete_corpusworkerversion" } }, { "model": "auth.permission", - "pk": 149, + "pk": 145, "fields": { "name": "Can view corpus worker version", - "content_type": 38, + "content_type": 37, "codename": "view_corpusworkerversion" } }, { "model": "auth.permission", - "pk": 150, + "pk": 146, "fields": { "name": "Can add data file", - "content_type": 39, + "content_type": 38, "codename": "add_datafile" } }, { "model": "auth.permission", - "pk": 151, + "pk": 147, "fields": { "name": "Can change data file", - "content_type": 39, + "content_type": 38, "codename": "change_datafile" } }, { "model": "auth.permission", - "pk": 152, + "pk": 148, "fields": { "name": "Can delete data file", - "content_type": 39, + "content_type": 38, "codename": "delete_datafile" } }, { "model": "auth.permission", - "pk": 153, + "pk": 149, "fields": { "name": "Can view data file", - "content_type": 39, + "content_type": 38, "codename": "view_datafile" } }, { "model": "auth.permission", - "pk": 154, + "pk": 150, "fields": { "name": "Can add git ref", - "content_type": 40, + "content_type": 39, "codename": "add_gitref" } }, { "model": "auth.permission", - "pk": 155, + "pk": 151, "fields": { "name": "Can change git ref", - "content_type": 40, + "content_type": 39, "codename": "change_gitref" } }, { "model": "auth.permission", - "pk": 156, + "pk": 152, "fields": { "name": "Can delete git ref", - "content_type": 40, + "content_type": 39, "codename": "delete_gitref" } }, { "model": "auth.permission", - "pk": 157, + "pk": 153, "fields": { "name": "Can view git ref", - "content_type": 40, + "content_type": 39, "codename": "view_gitref" } }, { "model": "auth.permission", - "pk": 158, + "pk": 154, "fields": { "name": "Can add process", - "content_type": 41, + "content_type": 40, "codename": "add_process" } }, { "model": "auth.permission", - "pk": 159, + "pk": 155, "fields": { "name": "Can change process", - "content_type": 41, + "content_type": 40, "codename": "change_process" } }, { "model": "auth.permission", - "pk": 160, + "pk": 156, "fields": { "name": "Can delete process", - "content_type": 41, + "content_type": 40, "codename": "delete_process" } }, { "model": "auth.permission", - "pk": 161, + "pk": 157, "fields": { "name": "Can view process", - "content_type": 41, + "content_type": 40, "codename": "view_process" } }, { "model": "auth.permission", - "pk": 162, + "pk": 158, "fields": { "name": "Can add process element", - "content_type": 42, + "content_type": 41, "codename": "add_processelement" } }, { "model": "auth.permission", - "pk": 163, + "pk": 159, "fields": { "name": "Can change process element", - "content_type": 42, + "content_type": 41, "codename": "change_processelement" } }, { "model": "auth.permission", - "pk": 164, + "pk": 160, "fields": { "name": "Can delete process element", - "content_type": 42, + "content_type": 41, "codename": "delete_processelement" } }, { "model": "auth.permission", - "pk": 165, + "pk": 161, "fields": { "name": "Can view process element", - "content_type": 42, + "content_type": 41, "codename": "view_processelement" } }, { "model": "auth.permission", - "pk": 166, + "pk": 162, "fields": { "name": "Can add repository", - "content_type": 43, + "content_type": 42, "codename": "add_repository" } }, { "model": "auth.permission", - "pk": 167, + "pk": 163, "fields": { "name": "Can change repository", - "content_type": 43, + "content_type": 42, "codename": "change_repository" } }, { "model": "auth.permission", - "pk": 168, + "pk": 164, "fields": { "name": "Can delete repository", - "content_type": 43, + "content_type": 42, "codename": "delete_repository" } }, { "model": "auth.permission", - "pk": 169, + "pk": 165, "fields": { "name": "Can view repository", - "content_type": 43, + "content_type": 42, "codename": "view_repository" } }, { "model": "auth.permission", - "pk": 170, + "pk": 166, "fields": { "name": "Can add revision", - "content_type": 44, + "content_type": 43, "codename": "add_revision" } }, { "model": "auth.permission", - "pk": 171, + "pk": 167, "fields": { "name": "Can change revision", - "content_type": 44, + "content_type": 43, "codename": "change_revision" } }, { "model": "auth.permission", - "pk": 172, + "pk": 168, "fields": { "name": "Can delete revision", - "content_type": 44, + "content_type": 43, "codename": "delete_revision" } }, { "model": "auth.permission", - "pk": 173, + "pk": 169, "fields": { "name": "Can view revision", - "content_type": 44, + "content_type": 43, "codename": "view_revision" } }, { "model": "auth.permission", - "pk": 174, + "pk": 170, "fields": { "name": "Can add worker", - "content_type": 45, + "content_type": 44, "codename": "add_worker" } }, { "model": "auth.permission", - "pk": 175, + "pk": 171, "fields": { "name": "Can change worker", - "content_type": 45, + "content_type": 44, "codename": "change_worker" } }, { "model": "auth.permission", - "pk": 176, + "pk": 172, "fields": { "name": "Can delete worker", - "content_type": 45, + "content_type": 44, "codename": "delete_worker" } }, { "model": "auth.permission", - "pk": 177, + "pk": 173, "fields": { "name": "Can view worker", - "content_type": 45, + "content_type": 44, "codename": "view_worker" } }, { "model": "auth.permission", - "pk": 178, + "pk": 174, "fields": { "name": "Can add worker activity", - "content_type": 46, + "content_type": 45, "codename": "add_workeractivity" } }, { "model": "auth.permission", - "pk": 179, + "pk": 175, "fields": { "name": "Can change worker activity", - "content_type": 46, + "content_type": 45, "codename": "change_workeractivity" } }, { "model": "auth.permission", - "pk": 180, + "pk": 176, "fields": { "name": "Can delete worker activity", - "content_type": 46, + "content_type": 45, "codename": "delete_workeractivity" } }, { "model": "auth.permission", - "pk": 181, + "pk": 177, "fields": { "name": "Can view worker activity", - "content_type": 46, + "content_type": 45, "codename": "view_workeractivity" } }, { "model": "auth.permission", - "pk": 182, + "pk": 178, "fields": { "name": "Can add worker configuration", - "content_type": 47, + "content_type": 46, "codename": "add_workerconfiguration" } }, { "model": "auth.permission", - "pk": 183, + "pk": 179, "fields": { "name": "Can change worker configuration", - "content_type": 47, + "content_type": 46, "codename": "change_workerconfiguration" } }, { "model": "auth.permission", - "pk": 184, + "pk": 180, "fields": { "name": "Can delete worker configuration", - "content_type": 47, + "content_type": 46, "codename": "delete_workerconfiguration" } }, { "model": "auth.permission", - "pk": 185, + "pk": 181, "fields": { "name": "Can view worker configuration", - "content_type": 47, + "content_type": 46, "codename": "view_workerconfiguration" } }, { "model": "auth.permission", - "pk": 186, + "pk": 182, "fields": { "name": "Can add worker type", - "content_type": 48, + "content_type": 47, "codename": "add_workertype" } }, { "model": "auth.permission", - "pk": 187, + "pk": 183, "fields": { "name": "Can change worker type", - "content_type": 48, + "content_type": 47, "codename": "change_workertype" } }, { "model": "auth.permission", - "pk": 188, + "pk": 184, "fields": { "name": "Can delete worker type", - "content_type": 48, + "content_type": 47, "codename": "delete_workertype" } }, { "model": "auth.permission", - "pk": 189, + "pk": 185, "fields": { "name": "Can view worker type", - "content_type": 48, + "content_type": 47, "codename": "view_workertype" } }, { "model": "auth.permission", - "pk": 190, + "pk": 186, "fields": { "name": "Can add worker version", - "content_type": 49, + "content_type": 48, "codename": "add_workerversion" } }, { "model": "auth.permission", - "pk": 191, + "pk": 187, "fields": { "name": "Can change worker version", - "content_type": 49, + "content_type": 48, "codename": "change_workerversion" } }, { "model": "auth.permission", - "pk": 192, + "pk": 188, "fields": { "name": "Can delete worker version", - "content_type": 49, + "content_type": 48, "codename": "delete_workerversion" } }, { "model": "auth.permission", - "pk": 193, + "pk": 189, "fields": { "name": "Can view worker version", - "content_type": 49, + "content_type": 48, "codename": "view_workerversion" } }, { "model": "auth.permission", - "pk": 194, + "pk": 190, "fields": { "name": "Can add worker run", - "content_type": 50, + "content_type": 49, "codename": "add_workerrun" } }, { "model": "auth.permission", - "pk": 195, + "pk": 191, "fields": { "name": "Can change worker run", - "content_type": 50, + "content_type": 49, "codename": "change_workerrun" } }, { "model": "auth.permission", - "pk": 196, + "pk": 192, "fields": { "name": "Can delete worker run", - "content_type": 50, + "content_type": 49, "codename": "delete_workerrun" } }, { "model": "auth.permission", - "pk": 197, + "pk": 193, "fields": { "name": "Can view worker run", - "content_type": 50, + "content_type": 49, "codename": "view_workerrun" } }, { "model": "auth.permission", - "pk": 198, + "pk": 194, "fields": { "name": "Can add process dataset", - "content_type": 51, + "content_type": 50, "codename": "add_processdataset" } }, { "model": "auth.permission", - "pk": 199, + "pk": 195, "fields": { "name": "Can change process dataset", - "content_type": 51, + "content_type": 50, "codename": "change_processdataset" } }, { "model": "auth.permission", - "pk": 200, + "pk": 196, "fields": { "name": "Can delete process dataset", - "content_type": 51, + "content_type": 50, "codename": "delete_processdataset" } }, { "model": "auth.permission", - "pk": 201, + "pk": 197, "fields": { "name": "Can view process dataset", - "content_type": 51, + "content_type": 50, "codename": "view_processdataset" } }, { "model": "auth.permission", - "pk": 202, + "pk": 198, "fields": { "name": "Can add dataset", - "content_type": 52, + "content_type": 51, "codename": "add_dataset" } }, { "model": "auth.permission", - "pk": 203, + "pk": 199, "fields": { "name": "Can change dataset", - "content_type": 52, + "content_type": 51, "codename": "change_dataset" } }, { "model": "auth.permission", - "pk": 204, + "pk": 200, "fields": { "name": "Can delete dataset", - "content_type": 52, + "content_type": 51, "codename": "delete_dataset" } }, { "model": "auth.permission", - "pk": 205, + "pk": 201, "fields": { "name": "Can view dataset", - "content_type": 52, + "content_type": 51, "codename": "view_dataset" } }, { "model": "auth.permission", - "pk": 206, + "pk": 202, "fields": { "name": "Can add metric key", - "content_type": 53, + "content_type": 52, "codename": "add_metrickey" } }, { "model": "auth.permission", - "pk": 207, + "pk": 203, "fields": { "name": "Can change metric key", - "content_type": 53, + "content_type": 52, "codename": "change_metrickey" } }, { "model": "auth.permission", - "pk": 208, + "pk": 204, "fields": { "name": "Can delete metric key", - "content_type": 53, + "content_type": 52, "codename": "delete_metrickey" } }, { "model": "auth.permission", - "pk": 209, + "pk": 205, "fields": { "name": "Can view metric key", - "content_type": 53, + "content_type": 52, "codename": "view_metrickey" } }, { "model": "auth.permission", - "pk": 210, + "pk": 206, "fields": { "name": "Can add model", - "content_type": 54, + "content_type": 53, "codename": "add_model" } }, { "model": "auth.permission", - "pk": 211, + "pk": 207, "fields": { "name": "Can change model", - "content_type": 54, + "content_type": 53, "codename": "change_model" } }, { "model": "auth.permission", - "pk": 212, + "pk": 208, "fields": { "name": "Can delete model", - "content_type": 54, + "content_type": 53, "codename": "delete_model" } }, { "model": "auth.permission", - "pk": 213, + "pk": 209, "fields": { "name": "Can view model", - "content_type": 54, + "content_type": 53, "codename": "view_model" } }, { "model": "auth.permission", - "pk": 214, + "pk": 210, "fields": { "name": "Can add model version", - "content_type": 55, + "content_type": 54, "codename": "add_modelversion" } }, { "model": "auth.permission", - "pk": 215, + "pk": 211, "fields": { "name": "Can change model version", - "content_type": 55, + "content_type": 54, "codename": "change_modelversion" } }, { "model": "auth.permission", - "pk": 216, + "pk": 212, "fields": { "name": "Can delete model version", - "content_type": 55, + "content_type": 54, "codename": "delete_modelversion" } }, { "model": "auth.permission", - "pk": 217, + "pk": 213, "fields": { "name": "Can view model version", - "content_type": 55, + "content_type": 54, "codename": "view_modelversion" } }, { "model": "auth.permission", - "pk": 218, + "pk": 214, "fields": { "name": "Can add metric value", - "content_type": 56, + "content_type": 55, "codename": "add_metricvalue" } }, { "model": "auth.permission", - "pk": 219, + "pk": 215, "fields": { "name": "Can change metric value", - "content_type": 56, + "content_type": 55, "codename": "change_metricvalue" } }, { "model": "auth.permission", - "pk": 220, + "pk": 216, "fields": { "name": "Can delete metric value", - "content_type": 56, + "content_type": 55, "codename": "delete_metricvalue" } }, { "model": "auth.permission", - "pk": 221, + "pk": 217, "fields": { "name": "Can view metric value", - "content_type": 56, + "content_type": 55, "codename": "view_metricvalue" } }, { "model": "auth.permission", - "pk": 222, + "pk": 218, "fields": { "name": "Can add dataset element", - "content_type": 57, + "content_type": 56, "codename": "add_datasetelement" } }, { "model": "auth.permission", - "pk": 223, + "pk": 219, "fields": { "name": "Can change dataset element", - "content_type": 57, + "content_type": 56, "codename": "change_datasetelement" } }, { "model": "auth.permission", - "pk": 224, + "pk": 220, "fields": { "name": "Can delete dataset element", - "content_type": 57, + "content_type": 56, "codename": "delete_datasetelement" } }, { "model": "auth.permission", - "pk": 225, + "pk": 221, "fields": { "name": "Can view dataset element", - "content_type": 57, + "content_type": 56, "codename": "view_datasetelement" } }, { "model": "ponos.farm", - "pk": "c19116b6-866f-4bb7-b447-3544629a8151", + "pk": "94055f35-9ac9-44a0-ac5d-386e132bea2c", "fields": { "name": "Wheat farm", - "seed": "03f762b3c505f0406a411b3fb3bbe4785c0e7763a05b515e7208b99ee93e955c" + "seed": "8cb2c985dcb2dcd344db193307b9049c1fe54d26faba25b417acbb2cdfcc9f41" } }, { "model": "ponos.task", - "pk": "4ca6c03f-9fee-4d44-8b6d-a3cfa5ae1ffb", + "pk": "1cb97d66-b673-4068-9824-ab8c33fdcb95", "fields": { "run": 0, "depth": 0, @@ -3967,22 +3931,22 @@ "agent": null, "requires_gpu": false, "gpu": null, - "process": "245bc206-350c-43d5-8db3-d98645c4eaa9", + "process": "3421ba72-b14c-4df0-a504-1e7e90abe4b4", "worker_run": null, "container": null, "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", "expiry": "2100-12-31T23:59:59.999Z", "extra_files": "{}", - "token": "xm8g6R1OTqmlJVsQwA9aYZfHNbR2Q0fWmB1BRSy4plw=", + "token": "/Mp4N1XhQsWzkPneokooyFdtnCiW4EeQi09Z6nn5LUo=", "parents": [] } }, { "model": "ponos.artifact", - "pk": "9a1b1d5d-ec3d-4a21-89da-0e930292467d", + "pk": "492ea45f-2c2d-4bf3-a461-016204fcfb03", "fields": { - "task": "4ca6c03f-9fee-4d44-8b6d-a3cfa5ae1ffb", + "task": "1cb97d66-b673-4068-9824-ab8c33fdcb95", "path": "/path/to/docker_build", "size": 42000, "content_type": "application/octet-stream", @@ -3992,11 +3956,11 @@ }, { "model": "training.dataset", - "pk": "3c74b8de-7c42-4f2a-921f-a5ef957b80da", + "pk": "5fb84be2-a906-4bc0-830b-f3a369021a32", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", "creator": 2, "task": null, "name": "Second Dataset", @@ -4007,11 +3971,11 @@ }, { "model": "training.dataset", - "pk": "87c1ca55-bb92-4b6d-90af-a2f50ae49df9", + "pk": "cd5bcbb9-450f-4bdf-9bfc-9640194c2f88", "fields": { "created": "2020-02-02T01:23:45.678Z", "updated": "2020-02-02T01:23:45.678Z", - "corpus": "fdbe4b5c-9475-4310-8877-eb07344e294a", + "corpus": "1204ee59-2b68-4dfb-a083-3df4996c2337", "creator": 2, "task": null, "name": "First Dataset", diff --git a/arkindex/documents/tests/test_allowed_metadata.py b/arkindex/documents/tests/test_allowed_metadata.py index b7c62412b1..00468e091a 100644 --- a/arkindex/documents/tests/test_allowed_metadata.py +++ b/arkindex/documents/tests/test_allowed_metadata.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status @@ -51,7 +53,8 @@ class TestAllowedMetaData(FixtureAPITestCase): list(expected_meta[3:]) ) - def test_list_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_private_corpus(self, has_access_mock): with self.assertNumQueries(1): response = self.client.get( reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.private_corpus.id)}), @@ -77,7 +80,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_create(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.corpus.id)}), data={"name": "flan", "type": "text"} @@ -93,7 +96,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_create_unique(self): self.client.force_login(self.user) self.corpus.allowed_metadatas.create(name="flan", type=MetaType.Text) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.corpus.id)}), data={"name": "flan", "type": "text"} @@ -122,21 +125,21 @@ class TestAllowedMetaData(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) - for role in [Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(5): - response = self.client.post( - reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.corpus.id)}), - data={"name": "flan", "type": "text"} - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_create_private_corpus(self): + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + with self.assertNumQueries(3): + response = self.client.post( + reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.corpus.id)}), + data={"name": "flan", "type": "text"} + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.private_corpus.id)}), data={"name": "flan", "type": "text"} @@ -145,7 +148,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_create_invalid(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-allowed-metadata", kwargs={"pk": str(self.corpus.id)}), data={"name": "flan", "type": "pouet"} @@ -175,9 +178,10 @@ class TestAllowedMetaData(FixtureAPITestCase): response = self.client.get(reverse("api:allowed-metadata-edit", kwargs={"corpus": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "pk": self.test_meta.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_retrieve_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_retrieve_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.private_corpus.id, "pk": self.test_meta.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -228,9 +232,10 @@ class TestAllowedMetaData(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.private_corpus.id, "pk": self.test_meta.id}), {"type": "url", "name": "newName"}, @@ -239,7 +244,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_update_invalid_type(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"type": "invalidType", "name": "newName"}, @@ -251,7 +256,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_update_missing_argument(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"name": "newName"}, @@ -263,7 +268,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_update_duplicate(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.put( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"type": self.allowed_meta[1].type.value, "name": self.allowed_meta[1].name}, @@ -273,21 +278,20 @@ class TestAllowedMetaData(FixtureAPITestCase): "detail": ["An AllowedMetaData with this type and name already exists in this corpus."] }) - def test_update_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) - for role in [Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), - {"type": self.allowed_meta[2].type.value, "name": self.allowed_meta[2].name}, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + with self.assertNumQueries(3): + response = self.client.put( + reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), + {"type": self.allowed_meta[2].type.value, "name": self.allowed_meta[2].name}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_partial_update(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"name": "newName"}, @@ -317,9 +321,10 @@ class TestAllowedMetaData(FixtureAPITestCase): {"type": "reference"}) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.private_corpus.id, "pk": self.test_meta.id}), {"type": "reference"}) @@ -327,7 +332,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_partial_update_invalid_type(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"type": "invalidType"}, @@ -339,7 +344,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_partial_update_duplicate(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), {"type": self.allowed_meta[2].type.value}, @@ -349,21 +354,20 @@ class TestAllowedMetaData(FixtureAPITestCase): "detail": ["An AllowedMetaData with this type and name already exists in this corpus."] }) - def test_partial_update_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) - for role in [Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), - {"type": self.allowed_meta[2].type.value}, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + with self.assertNumQueries(3): + response = self.client.patch( + reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id}), + {"type": self.allowed_meta[2].type.value}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_destroy(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -372,7 +376,7 @@ class TestAllowedMetaData(FixtureAPITestCase): def test_destroy_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -395,17 +399,17 @@ class TestAllowedMetaData(FixtureAPITestCase): response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_destroy_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.private_corpus.id, "pk": self.test_meta.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_destroy_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) - for role in [Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(5): - response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + with self.assertNumQueries(3): + response = self.client.delete(reverse("api:allowed-metadata-edit", kwargs={"corpus": self.corpus.id, "pk": self.test_meta.id})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/arkindex/documents/tests/test_bulk_classification.py b/arkindex/documents/tests/test_bulk_classification.py index d722c54e41..ce5ab5e7d7 100644 --- a/arkindex/documents/tests/test_bulk_classification.py +++ b/arkindex/documents/tests/test_bulk_classification.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status @@ -24,15 +26,17 @@ class TestBulkClassification(FixtureAPITestCase): response = self.client.post(reverse("api:classification-bulk"), format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_wrong_acl(self, filter_rights_mock): """ The user must have access to the parent element """ + filter_rights_mock.return_value = Corpus.objects.none() self.client.force_login(self.user) private_page = self.private_corpus.elements.create( type=self.private_corpus.types.create(slug="page"), ) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -61,7 +65,7 @@ class TestBulkClassification(FixtureAPITestCase): Classifications must be linked to a worker run """ self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -89,7 +93,7 @@ class TestBulkClassification(FixtureAPITestCase): def test_worker_run_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -119,7 +123,7 @@ class TestBulkClassification(FixtureAPITestCase): self.dog_class.delete() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -145,7 +149,7 @@ class TestBulkClassification(FixtureAPITestCase): Test the bulk classification API deletes previous classifications with the same worker run """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -205,7 +209,7 @@ class TestBulkClassification(FixtureAPITestCase): Test the bulk classification API prevents creating classifications with duplicate ML classes """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -297,7 +301,7 @@ class TestBulkClassification(FixtureAPITestCase): other_worker_run = process2.worker_runs.create(version=self.worker_run.version, parents=[]) self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -325,7 +329,7 @@ class TestBulkClassification(FixtureAPITestCase): A regular user can create classifications with a WorkerRun of their own local process """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -396,7 +400,7 @@ class TestBulkClassification(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:classification-bulk"), format="json", @@ -445,7 +449,7 @@ class TestBulkClassification(FixtureAPITestCase): self.assertNotEqual(self.worker_run.process_id, local_worker_run.process_id) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:classification-bulk"), format="json", diff --git a/arkindex/documents/tests/test_bulk_element_transcriptions.py b/arkindex/documents/tests/test_bulk_element_transcriptions.py index 8075296695..3ef2499efb 100644 --- a/arkindex/documents/tests/test_bulk_element_transcriptions.py +++ b/arkindex/documents/tests/test_bulk_element_transcriptions.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import patch from django.db.models import Count from django.test import override_settings @@ -13,9 +14,6 @@ from arkindex.project.tests import FixtureAPITestCase class TestBulkElementTranscriptions(FixtureAPITestCase): - """ - Tests for text element creation view - """ @classmethod def setUpTestData(cls): @@ -90,7 +88,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence, orientation in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -150,7 +148,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence, orientation, element_confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -211,7 +209,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): self.assertEqual(created_elts.count(), 1) self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -253,7 +251,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -302,7 +300,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.huge_page.id}), format="json", @@ -334,12 +332,13 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_bulk_transcriptions_private(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_bulk_transcriptions_private(self, filter_rights_mock): """ Accessing a non writeable element triggers a 404_NOT_FOUND """ self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.private_page.id}), format="json", @@ -372,7 +371,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): def test_bulk_transcriptions_version_xor_run(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -469,7 +468,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -536,7 +535,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": top_level.id}), format="json", @@ -584,7 +583,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -616,7 +615,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence, orientation in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -647,7 +646,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.rotated_page.id}), format="json", @@ -684,7 +683,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(13): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.mirrored_page.id}), format="json", @@ -721,7 +720,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): } for poly, text, confidence in transcriptions] } self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -799,7 +798,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -832,7 +831,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(14): + with self.assertNumQueries(12): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", @@ -872,7 +871,7 @@ class TestBulkElementTranscriptions(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(14): + with self.assertNumQueries(12): response = self.client.post( reverse("api:element-transcriptions-bulk", kwargs={"pk": self.page.id}), format="json", diff --git a/arkindex/documents/tests/test_bulk_elements.py b/arkindex/documents/tests/test_bulk_elements.py index 2548632c65..21360cb428 100644 --- a/arkindex/documents/tests/test_bulk_elements.py +++ b/arkindex/documents/tests/test_bulk_elements.py @@ -1,10 +1,11 @@ +from unittest.mock import patch from uuid import uuid4 from django.contrib.gis.geos import LineString from django.urls import reverse from rest_framework import status -from arkindex.documents.models import Element, ElementPath +from arkindex.documents.models import Corpus, Element, ElementPath from arkindex.process.models import ProcessMode, WorkerRun, WorkerVersion from arkindex.project.tests import FixtureAPITestCase from arkindex.users.models import Role @@ -87,7 +88,8 @@ class TestBulkElements(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_bulk_create_writable_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_bulk_create_writable_corpus(self, filter_rights_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) response = self.client.post( @@ -114,7 +116,7 @@ class TestBulkElements(FixtureAPITestCase): def test_bulk_create_requires_elements(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data={ @@ -128,7 +130,7 @@ class TestBulkElements(FixtureAPITestCase): def test_bulk_create_missing_types(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data={ @@ -158,7 +160,7 @@ class TestBulkElements(FixtureAPITestCase): element_path1, element_path2 = self.element.paths.order_by("path__0") self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=self.payload, @@ -203,7 +205,7 @@ class TestBulkElements(FixtureAPITestCase): # Try to create a sub element on that zone self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(element.id)}), data={ @@ -234,7 +236,7 @@ class TestBulkElements(FixtureAPITestCase): def test_bulk_create_negative_polygon(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), { @@ -337,7 +339,7 @@ class TestBulkElements(FixtureAPITestCase): } ] } - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=payload, @@ -354,7 +356,7 @@ class TestBulkElements(FixtureAPITestCase): def test_bulk_create_unknown_worker_run(self): self.client.force_login(self.user) random_uuid = str(uuid4()) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), { @@ -379,7 +381,7 @@ class TestBulkElements(FixtureAPITestCase): Worker run attribute is required, worker version attribute is forbidden """ self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data={ @@ -448,7 +450,7 @@ class TestBulkElements(FixtureAPITestCase): task = self.worker_run.process.tasks.first() payload = {**self.payload, "worker_run_id": str(other_worker_run.id)} - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=payload, @@ -465,7 +467,7 @@ class TestBulkElements(FixtureAPITestCase): A regular user can create elements with a WorkerRun of their own local process """ self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=self.payload, @@ -514,7 +516,7 @@ class TestBulkElements(FixtureAPITestCase): task = self.worker_run.process.tasks.first() payload = {**self.payload, "worker_run_id": str(self.worker_run.id)} - with self.assertNumQueries(12): + with self.assertNumQueries(10): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=payload, @@ -561,7 +563,7 @@ class TestBulkElements(FixtureAPITestCase): payload = {**self.payload, "worker_run_id": str(self.local_worker_run.id)} - with self.assertNumQueries(12): + with self.assertNumQueries(10): response = self.client.post( reverse("api:elements-bulk-create", kwargs={"pk": str(self.element.id)}), data=payload, diff --git a/arkindex/documents/tests/test_bulk_transcription_entities.py b/arkindex/documents/tests/test_bulk_transcription_entities.py index 6a49250e42..fc6b747da3 100644 --- a/arkindex/documents/tests/test_bulk_transcription_entities.py +++ b/arkindex/documents/tests/test_bulk_transcription_entities.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import patch from django.urls import reverse from rest_framework import status @@ -39,10 +40,11 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_requires_contributor(self, has_access_mock): self.user.rights.update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:transcription-entities-bulk", kwargs={"pk": self.transcription.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { @@ -96,7 +98,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): worker_version=self.local_worker_run.version, ) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -120,7 +122,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): def test_entity_fields_validation(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -150,7 +152,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): "length": 5, "confidence": 0.05, } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -242,7 +244,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -279,7 +281,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): ) self.assertEqual(self.transcription.transcription_entities.count(), 0) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -361,7 +363,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ @@ -396,7 +398,7 @@ class TestBulkTranscriptionEntities(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.post( reverse("api:transcription-entities-bulk", kwargs={"pk": str(self.transcription.id)}), data={ diff --git a/arkindex/documents/tests/test_bulk_transcriptions.py b/arkindex/documents/tests/test_bulk_transcriptions.py index ece36ea4cd..8c84c55a09 100644 --- a/arkindex/documents/tests/test_bulk_transcriptions.py +++ b/arkindex/documents/tests/test_bulk_transcriptions.py @@ -1,7 +1,9 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status -from arkindex.documents.models import TextOrientation +from arkindex.documents.models import Corpus, TextOrientation from arkindex.process.models import ProcessMode, WorkerRun, WorkerVersion from arkindex.project.tests import FixtureAPITestCase @@ -30,12 +32,13 @@ class TestBulkTranscriptions(FixtureAPITestCase): response = self.client.post(reverse("api:transcription-bulk")) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_bulk_transcriptions_not_found(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_bulk_transcriptions_not_found(self, filter_rights_mock): self.client.force_login(self.user) self.user.rights.all().delete() forbidden_element = self.corpus.elements.get(name="Volume 1, page 1r") - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:transcription-bulk"), { "worker_run_id": str(self.local_worker_run.id), "transcriptions": [ @@ -121,7 +124,7 @@ class TestBulkTranscriptions(FixtureAPITestCase): test_element = self.corpus.elements.get(name="Volume 2, page 1r") self.assertFalse(test_element.transcriptions.exists()) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:transcription-bulk"), { "worker_run_id": str(self.local_worker_run.id), "transcriptions": [ @@ -323,7 +326,7 @@ class TestBulkTranscriptions(FixtureAPITestCase): self.assertFalse(element1.transcriptions.exists()) self.assertFalse(element2.transcriptions.exists()) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:transcription-bulk"), { "worker_run_id": str(self.local_worker_run.id), "transcriptions": [ @@ -413,7 +416,7 @@ class TestBulkTranscriptions(FixtureAPITestCase): element = self.corpus.elements.get(name="Volume 2, page 1r") self.assertFalse(element.transcriptions.exists()) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-bulk"), format="json", @@ -465,7 +468,7 @@ class TestBulkTranscriptions(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-bulk"), format="json", diff --git a/arkindex/documents/tests/test_classes.py b/arkindex/documents/tests/test_classes.py index e0ff7d2c02..efe439164b 100644 --- a/arkindex/documents/tests/test_classes.py +++ b/arkindex/documents/tests/test_classes.py @@ -1,3 +1,4 @@ +from unittest.mock import patch from django.test import override_settings from django.urls import reverse @@ -163,7 +164,8 @@ class TestClasses(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"search": ["There cannot be more than 3 unique search terms."]}) - def test_corpus_classes_corpus_rights(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_corpus_classes_corpus_rights(self, has_access_mock): self.client.force_login(self.user) private_corpus = Corpus.objects.create(name="private") response = self.client.post(reverse("api:corpus-classes", kwargs={"pk": private_corpus.pk}), {}) @@ -233,7 +235,8 @@ class TestClasses(FixtureAPITestCase): response = self.client.put(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id}), {"name": "new name"}) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_requires_contributor(self, has_access_mock): self.user.rights.update(level=Role.Guest.value) self.client.force_login(self.user) response = self.client.put(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id}), {"name": "new name"}) @@ -262,7 +265,8 @@ class TestClasses(FixtureAPITestCase): response = self.client.patch(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id}), {"name": "new name"}) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_requires_contributor(self, has_access_mock): self.user.rights.update(level=Role.Guest.value) self.client.force_login(self.user) response = self.client.patch(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id}), {"name": "new name"}) @@ -295,7 +299,8 @@ class TestClasses(FixtureAPITestCase): response = self.client.delete(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_destroy_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_requires_contributor(self, has_access_mock): self.user.rights.update(level=Role.Guest.value) self.client.force_login(self.user) response = self.client.delete(reverse("api:ml-class-retrieve", kwargs={"corpus": self.corpus.id, "mlclass": self.text.id})) diff --git a/arkindex/documents/tests/test_classification.py b/arkindex/documents/tests/test_classification.py index 4270e08a16..04a2e5c434 100644 --- a/arkindex/documents/tests/test_classification.py +++ b/arkindex/documents/tests/test_classification.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import patch from django.test import override_settings from django.urls import reverse @@ -31,7 +32,7 @@ class TestClassifications(FixtureAPITestCase): Creating a manual classification set auto fields correctly """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -62,7 +63,7 @@ class TestClassifications(FixtureAPITestCase): A manual classification may be created specifying the version as null """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:classification-create"), data={ @@ -84,7 +85,7 @@ class TestClassifications(FixtureAPITestCase): reverse("api:classification-create"), {"element": str(self.element.id), "ml_class": str(self.text.id)} ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(*request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -124,7 +125,8 @@ class TestClassifications(FixtureAPITestCase): "non_field_errors": ["A classification from this worker run already exists for this element and this class."] }) - def test_create_writable_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_writable_corpus(self, filter_rights_mock): """ Require the element and ML class to be in a writable corpus """ @@ -133,7 +135,7 @@ class TestClassifications(FixtureAPITestCase): self.element.corpus = self.private_corpus self.element.save() - with self.assertNumQueries(6): + with self.assertNumQueries(2): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(ml_class.id), @@ -153,7 +155,7 @@ class TestClassifications(FixtureAPITestCase): self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) ml_class = self.private_corpus.ml_classes.create(name="Heatmor") - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(ml_class.id), @@ -170,7 +172,7 @@ class TestClassifications(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -206,7 +208,7 @@ class TestClassifications(FixtureAPITestCase): def test_create_worker_version(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -224,7 +226,7 @@ class TestClassifications(FixtureAPITestCase): A regular user can create a classification with a WorkerRun of their own local process """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -261,7 +263,7 @@ class TestClassifications(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:classification-create"), { @@ -302,7 +304,7 @@ class TestClassifications(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:classification-create"), { @@ -389,7 +391,7 @@ class TestClassifications(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:classification-create"), { @@ -409,7 +411,7 @@ class TestClassifications(FixtureAPITestCase): def test_create_worker_version_xor_worker_run(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -424,7 +426,7 @@ class TestClassifications(FixtureAPITestCase): def test_create_worker_run_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -441,7 +443,7 @@ class TestClassifications(FixtureAPITestCase): Ensure CreateClassification accepts a confidence of 0 """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -477,7 +479,7 @@ class TestClassifications(FixtureAPITestCase): self.assertEqual(self.element.classifications.count(), 1) # Create a manual classification with the same class - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:classification-create"), { "element": str(self.element.id), "ml_class": str(self.text.id), @@ -516,7 +518,7 @@ class TestClassifications(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.put(reverse("api:classification-validate", kwargs={"pk": classification.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -567,7 +569,7 @@ class TestClassifications(FixtureAPITestCase): confidence=.1, ) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.put(reverse("api:classification-reject", kwargs={"pk": classification.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -595,7 +597,7 @@ class TestClassifications(FixtureAPITestCase): self.client.force_login(self.user) classification = self.element.classifications.create(ml_class=self.text, confidence=.42) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.put(reverse("api:classification-reject", kwargs={"pk": classification.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -632,7 +634,7 @@ class TestClassifications(FixtureAPITestCase): ) # First try to reject - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.put(reverse("api:classification-reject", kwargs={"pk": classification.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -686,7 +688,8 @@ class TestClassifications(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"non_field_errors": ["Selection is not available on this instance."]}) - def test_create_selection_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_selection_private_corpus(self, has_access_mock): self.client.force_login(self.user) with self.assertNumQueries(5): response = self.client.post( @@ -695,11 +698,12 @@ class TestClassifications(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_create_selection_writable_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_selection_writable_corpus(self, has_access_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:classification-selection"), data={"corpus_id": self.corpus.id, "ml_class": self.text.id, "mode": "create"} diff --git a/arkindex/documents/tests/test_corpus.py b/arkindex/documents/tests/test_corpus.py index 4aeade12e3..884d60ccdb 100644 --- a/arkindex/documents/tests/test_corpus.py +++ b/arkindex/documents/tests/test_corpus.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from unittest import expectedFailure from unittest.mock import call, patch from uuid import uuid4 @@ -73,6 +74,7 @@ class TestCorpus(FixtureAPITestCase): mock_now.return_value = FAKE_NOW cls.corpus_hidden = Corpus.objects.create(name="C Hidden") + @expectedFailure def test_anon(self): # An anonymous user has only access to public with self.assertNumQueries(4): @@ -104,10 +106,11 @@ class TestCorpus(FixtureAPITestCase): ] ) + @expectedFailure def test_user(self): # A normal user has access to public + its private self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(6): response = self.client.get(reverse("api:corpus")) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -222,6 +225,7 @@ class TestCorpus(FixtureAPITestCase): self.assertEqual(len(data), 13) self.assertSetEqual({corpus["top_level_type"] for corpus in data}, {None, "top_level"}) + @expectedFailure def test_mixin(self): vol1 = Element.objects.get(name="Volume 1") vol2 = Element.objects.get(name="Volume 2") @@ -293,7 +297,6 @@ class TestCorpus(FixtureAPITestCase): }) self.assertEqual(response.status_code, status.HTTP_201_CREATED) corpus = Corpus.objects.get(name="New Corpus", description="Some description", public=False) - self.assertNotIn(self.corpus_private, Corpus.objects.admin(self.user)) # Assert defaults types are set on the new corpus self.assertCountEqual( @@ -342,7 +345,7 @@ class TestCorpus(FixtureAPITestCase): "description": self.corpus_public.description, "public": True, "indexable": False, - "rights": ["read"], + "rights": ["read", "write", "admin"], "created": DB_CREATED, "authorized_users": 1, "top_level_type": None, @@ -350,7 +353,7 @@ class TestCorpus(FixtureAPITestCase): def test_retrieve(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:corpus-retrieve", kwargs={"pk": self.corpus_private.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(response.json(), { @@ -359,7 +362,7 @@ class TestCorpus(FixtureAPITestCase): "description": self.corpus_private.description, "public": False, "indexable": False, - "rights": ["read", "write"], + "rights": ["read", "write", "admin"], "types": [], "created": DB_CREATED, "authorized_users": 2, @@ -371,15 +374,11 @@ class TestCorpus(FixtureAPITestCase): response = self.client.get(reverse("api:corpus-retrieve", kwargs={"pk": uuid4()})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_retrieve_requires_login(self): - response = self.client.get(reverse("api:corpus-retrieve", kwargs={"pk": self.corpus_private.id})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_retrieve_public_ignores_verified(self): self.user.verified_email = False self.user.save() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:corpus-retrieve", kwargs={"pk": self.corpus_public.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -399,6 +398,7 @@ class TestCorpus(FixtureAPITestCase): "top_level_type": None, }) + @expectedFailure def test_retrieve_private_requires_guest(self): self.user.rights.all().delete() self.client.force_login(self.user) @@ -482,6 +482,7 @@ class TestCorpus(FixtureAPITestCase): }) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_partial_update_requires_admin_right_on_corpus(self): for role in [Role.Guest, Role.Contributor]: with self.subTest(role=role): @@ -591,6 +592,7 @@ class TestCorpus(FixtureAPITestCase): }) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_update_requires_admin_right_on_corpus(self): for role in [Role.Guest, Role.Contributor]: with self.subTest(role=role): @@ -647,6 +649,7 @@ class TestCorpus(FixtureAPITestCase): response = self.client.delete(reverse("api:corpus-retrieve", kwargs={"pk": self.corpus_private.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_delete_requires_admin_right(self): for role in [Role.Guest, Role.Contributor]: with self.subTest(role=role): @@ -655,6 +658,7 @@ class TestCorpus(FixtureAPITestCase): response = self.client.delete(reverse("api:corpus-retrieve", kwargs={"pk": self.corpus_private.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_delete_no_right_not_found(self): self.user.rights.all().delete() self.client.force_login(self.user) @@ -706,6 +710,7 @@ class TestCorpus(FixtureAPITestCase): response = self.client.delete(reverse("api:corpus-delete-selection", kwargs={"pk": self.corpus_private.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_delete_selected_elements_requires_admin_right(self): self.assertNotIn(self.corpus_private, Corpus.objects.admin(self.user)) self.client.force_login(self.user) diff --git a/arkindex/documents/tests/test_corpus_authorized_users.py b/arkindex/documents/tests/test_corpus_authorized_users.py index 2d913b9fca..8779b27b48 100644 --- a/arkindex/documents/tests/test_corpus_authorized_users.py +++ b/arkindex/documents/tests/test_corpus_authorized_users.py @@ -1,3 +1,5 @@ +from unittest import expectedFailure + from django.urls import reverse from rest_framework import status @@ -68,6 +70,7 @@ class TestCorpusAuthorizedUsers(FixtureAPITestCase): ] ) + @expectedFailure def test_user_one(self): self.client.force_login(self.user_one) response = self.client.get(reverse("api:corpus")) @@ -103,6 +106,7 @@ class TestCorpusAuthorizedUsers(FixtureAPITestCase): ] ) + @expectedFailure def test_user_two(self): self.client.force_login(self.user_two) response = self.client.get(reverse("api:corpus")) @@ -133,6 +137,7 @@ class TestCorpusAuthorizedUsers(FixtureAPITestCase): ] ) + @expectedFailure def test_user_three(self): self.client.force_login(self.user_three) response = self.client.get(reverse("api:corpus")) diff --git a/arkindex/documents/tests/test_create_elements.py b/arkindex/documents/tests/test_create_elements.py index 006f9c3179..b39fa86261 100644 --- a/arkindex/documents/tests/test_create_elements.py +++ b/arkindex/documents/tests/test_create_elements.py @@ -80,7 +80,7 @@ class TestCreateElements(FixtureAPITestCase): # Create a Volume self.client.force_login(self.user) request = self.make_create_request("my new volume") - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -125,7 +125,7 @@ class TestCreateElements(FixtureAPITestCase): name="The castle of my dreams", image=str(self.image.id), ) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -180,7 +180,7 @@ class TestCreateElements(FixtureAPITestCase): name="The castle of my dreams", polygon=polygon, ) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -200,7 +200,7 @@ class TestCreateElements(FixtureAPITestCase): name="Castle story", elt_type="act" ) - with self.assertNumQueries(14): + with self.assertNumQueries(11): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) act = Element.objects.get(id=response.json()["id"]) @@ -216,7 +216,7 @@ class TestCreateElements(FixtureAPITestCase): name="The castle of my dreams again", polygon=[[10, 10], [10, 40], [40, 40], [40, 10], [10, 10]], ) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -232,7 +232,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), polygon=[[0, 0], [10, 10], [40, 40], [-10, 10], [0, 0]] ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -250,7 +250,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), polygon=polygon ) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -300,7 +300,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.huge_image.id), polygon=polygon ) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) self.assertListEqual(response.json()["zone"]["polygon"], polygon) @@ -315,7 +315,7 @@ class TestCreateElements(FixtureAPITestCase): corpus=str(new_corpus.id), elt_type="act" ) - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -336,7 +336,7 @@ class TestCreateElements(FixtureAPITestCase): elt_type="volume", name="something", ) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) new_vol = new_corpus.elements.get() @@ -351,7 +351,7 @@ class TestCreateElements(FixtureAPITestCase): elt_type="kartoffelsalad", name="something", ) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -367,7 +367,7 @@ class TestCreateElements(FixtureAPITestCase): ) self.image.status = S3FileStatus.Error self.image.save() - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) element = self.corpus.elements.get(id=response.json()["id"]) @@ -384,7 +384,7 @@ class TestCreateElements(FixtureAPITestCase): self.image.height = 0 self.image.status = S3FileStatus.Unchecked self.image.save() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"image": ["This image does not have valid dimensions."]}) @@ -395,7 +395,7 @@ class TestCreateElements(FixtureAPITestCase): name="slim output !", ) request["path"] += "?slim_output=true" - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) element = self.corpus.elements.get(id=response.json()["id"]) @@ -412,7 +412,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), ) request["path"] += "?slim_output=true" - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) element = self.corpus.elements.get(id=response.json()["id"]) @@ -449,7 +449,7 @@ class TestCreateElements(FixtureAPITestCase): if mirrored is not None: request["data"]["mirrored"] = mirrored - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -476,7 +476,7 @@ class TestCreateElements(FixtureAPITestCase): rotation_angle=rotation_angle, ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -497,7 +497,7 @@ class TestCreateElements(FixtureAPITestCase): image=str(self.image.id), polygon=polygon ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -514,7 +514,7 @@ class TestCreateElements(FixtureAPITestCase): confidence=0.42, ) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -541,7 +541,7 @@ class TestCreateElements(FixtureAPITestCase): confidence=confidence, ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -555,7 +555,7 @@ class TestCreateElements(FixtureAPITestCase): """ self.client.force_login(self.local_worker_run.process.creator) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.post( reverse("api:elements-create"), { @@ -582,7 +582,7 @@ class TestCreateElements(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:elements-create"), { @@ -617,7 +617,7 @@ class TestCreateElements(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:elements-create"), { @@ -699,7 +699,7 @@ class TestCreateElements(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:elements-create"), { @@ -724,7 +724,7 @@ class TestCreateElements(FixtureAPITestCase): elt_type="act", worker_run_id=random_uuid, ) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -738,7 +738,7 @@ class TestCreateElements(FixtureAPITestCase): worker_version=str(self.worker_version.id), worker_run_id=str(self.local_worker_run.id), ) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -755,7 +755,7 @@ class TestCreateElements(FixtureAPITestCase): worker_version=str(self.worker_version.id), ) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(**request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/arkindex/documents/tests/test_create_parent_selection.py b/arkindex/documents/tests/test_create_parent_selection.py index 64e562bb4f..f60e35e3e7 100644 --- a/arkindex/documents/tests/test_create_parent_selection.py +++ b/arkindex/documents/tests/test_create_parent_selection.py @@ -41,7 +41,7 @@ class TestMoveSelection(FixtureAPITestCase): @override_settings(ARKINDEX_FEATURES={"selection": False}) def test_disabled(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(self.parent.id), @@ -52,14 +52,15 @@ class TestMoveSelection(FixtureAPITestCase): ) @override_settings(ARKINDEX_FEATURES={"selection": True}) - def test_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_wrong_acl(self, filter_rights_mock): private_corpus = Corpus.objects.create(name="private", public=False) private_element = private_corpus.elements.create( type=private_corpus.types.create(slug="folder"), ) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(2): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(private_corpus.id), "parent_id": str(private_element.id), @@ -77,7 +78,7 @@ class TestMoveSelection(FixtureAPITestCase): def test_wrong_parent(self): self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": "12341234-1234-1234-1234-123412341234" @@ -92,7 +93,7 @@ class TestMoveSelection(FixtureAPITestCase): def test_same_parent(self): self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(self.page.id), @@ -110,7 +111,7 @@ class TestMoveSelection(FixtureAPITestCase): parent = corpus2.elements.create(type=corpus2.types.create(slug="folder")) self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(parent.id), @@ -126,7 +127,7 @@ class TestMoveSelection(FixtureAPITestCase): parent = self.corpus.elements.get(name="Volume 1") self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(parent.id), @@ -143,7 +144,7 @@ class TestMoveSelection(FixtureAPITestCase): parent_id = self.page.id self.client.force_login(self.user) self.user.selected_elements.add(target) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(parent_id), @@ -161,7 +162,7 @@ class TestMoveSelection(FixtureAPITestCase): self.user.selected_elements.add(self.page) another_page = self.corpus.elements.get(name="Volume 1, page 1v") self.user.selected_elements.add(another_page) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post(reverse("api:create-parent-selection"), { "corpus_id": str(self.corpus.id), "parent_id": str(self.parent.id), diff --git a/arkindex/documents/tests/test_create_transcriptions.py b/arkindex/documents/tests/test_create_transcriptions.py index f110bfeca9..d29c2f7f98 100644 --- a/arkindex/documents/tests/test_create_transcriptions.py +++ b/arkindex/documents/tests/test_create_transcriptions.py @@ -1,3 +1,4 @@ +from unittest.mock import patch from uuid import uuid4 from django.test import override_settings @@ -41,9 +42,10 @@ class TestTranscriptionCreate(FixtureAPITestCase): "detail": "Authentication credentials were not provided." }) - def test_write_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_write_right(self, has_access_mock): self.client.force_login(self.private_read_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.private_page.id}), format="json", @@ -54,9 +56,10 @@ class TestTranscriptionCreate(FixtureAPITestCase): "detail": "A write access to the element's corpus is required." }) - def test_no_read_right(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_no_read_right(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.private_page.id}), format="json", @@ -66,7 +69,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_no_element(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": uuid4()}), format="json", @@ -76,7 +79,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_manual(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -104,7 +107,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): """ self.client.force_login(self.user) ts = self.line.transcriptions.create(text="GLOUBIBOULGA") - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -123,7 +126,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): Check that a transcription is created with the specified orientation """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -147,7 +150,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): Specifying an invalid text-orientation causes an error """ self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -158,7 +161,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): @override_settings(ARKINDEX_FEATURES={"search": False}) def test_no_search(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -168,7 +171,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_worker_version(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -189,7 +192,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -227,7 +230,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -263,7 +266,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -349,7 +352,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -368,7 +371,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_worker_run_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -386,7 +389,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_worker_run_required_confidence(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -402,7 +405,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): def test_worker_version_xor_worker_run(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", @@ -424,7 +427,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): null_zone_page = self.corpus.elements.create(type=self.page.type) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": null_zone_page.id}), format="json", @@ -450,7 +453,7 @@ class TestTranscriptionCreate(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:transcription-create", kwargs={"pk": self.line.id}), format="json", diff --git a/arkindex/documents/tests/test_destroy_elements.py b/arkindex/documents/tests/test_destroy_elements.py index f9403f3745..da6312ac19 100644 --- a/arkindex/documents/tests/test_destroy_elements.py +++ b/arkindex/documents/tests/test_destroy_elements.py @@ -30,14 +30,15 @@ class TestDestroyElements(FixtureAPITestCase): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_element_destroy_acl(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_element_destroy_acl(self, has_access_mock): self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) castle_story = self.private_corpus.elements.create( type=self.volume_type, name="Castle story" ) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(castle_story.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual( @@ -53,7 +54,7 @@ class TestDestroyElements(FixtureAPITestCase): name="Castle story" ) self.assertTrue(self.corpus.elements.filter(id=castle_story.id).exists()) - with self.assertNumQueries(7): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(castle_story.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -80,7 +81,7 @@ class TestDestroyElements(FixtureAPITestCase): with self.subTest(delete_children=delete_children): delay_mock.reset_mock() - with self.assertNumQueries(7): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:element-retrieve", kwargs={"pk": str(castle_story.id)}) + f"?delete_children={delete_children}", @@ -109,7 +110,7 @@ class TestDestroyElements(FixtureAPITestCase): name="Castle story", creator=self.user, ) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(castle_story.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -123,7 +124,8 @@ class TestDestroyElements(FixtureAPITestCase): "description": "Element deletion", }) - def test_element_destroy_creator_acl(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_element_destroy_creator_acl(self, has_access_mock): """ An element's creator cannot delete without write access """ @@ -134,7 +136,7 @@ class TestDestroyElements(FixtureAPITestCase): name="Castle story", creator=self.user, ) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(castle_story.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual( @@ -148,7 +150,7 @@ class TestDestroyElements(FixtureAPITestCase): """ Dataset.objects.get(name="First Dataset").dataset_elements.create(element=self.vol, set="test") self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You cannot delete an element that is part of a dataset."}) @@ -159,7 +161,7 @@ class TestDestroyElements(FixtureAPITestCase): We can now delete a non-empty element """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -316,11 +318,12 @@ class TestDestroyElements(FixtureAPITestCase): self.assertFalse(delay_mock.called) @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") - def test_destroy_corpus_elements_requires_admin(self, delay_mock): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_corpus_elements_requires_admin(self, has_access_mock, delay_mock): self.corpus.memberships.update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:corpus-elements", kwargs={"corpus": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have admin access to this corpus."}) @@ -332,7 +335,7 @@ class TestDestroyElements(FixtureAPITestCase): self.client.force_login(self.user) self.assertFalse(self.corpus.elements.filter(name="blablablabla").exists()) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:corpus-elements", kwargs={"corpus": self.corpus.id}), QUERY_STRING="name=blablablabla", @@ -344,7 +347,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_corpus_elements(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:corpus-elements", kwargs={"corpus": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -361,7 +364,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_corpus_elements_delete_children(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:corpus-elements", kwargs={"corpus": self.corpus.id}), QUERY_STRING="delete_children=false" @@ -388,11 +391,12 @@ class TestDestroyElements(FixtureAPITestCase): self.assertFalse(delay_mock.called) @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") - def test_destroy_element_children_requires_admin(self, delay_mock): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_element_children_requires_admin(self, has_access_mock, delay_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:elements-children", kwargs={"pk": self.vol.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have admin access to this corpus."}) @@ -404,7 +408,7 @@ class TestDestroyElements(FixtureAPITestCase): self.client.force_login(self.user) element = self.corpus.elements.create(type=self.volume_type, name="Lonely element") - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:elements-children", kwargs={"pk": element.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -413,7 +417,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_element_children(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:elements-children", kwargs={"pk": self.vol.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -434,7 +438,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_element_children_delete_children(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:elements-children", kwargs={"pk": self.vol.id}), QUERY_STRING="delete_children=false" @@ -465,11 +469,12 @@ class TestDestroyElements(FixtureAPITestCase): self.assertFalse(delay_mock.called) @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") - def test_destroy_element_parents_requires_writable(self, delay_mock): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_element_parents_requires_writable(self, has_access_mock, delay_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:elements-parents", kwargs={"pk": self.surface.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have admin access to this corpus."}) @@ -481,7 +486,7 @@ class TestDestroyElements(FixtureAPITestCase): self.client.force_login(self.user) element = self.corpus.elements.create(type=self.volume_type, name="Lonely element") - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:elements-parents", kwargs={"pk": element.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -490,7 +495,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_element_parents(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:elements-parents", kwargs={"pk": self.surface.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -511,7 +516,7 @@ class TestDestroyElements(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.element_trash.delay") def test_destroy_element_parents_delete_parents(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:elements-parents", kwargs={"pk": self.surface.id}), QUERY_STRING="delete_children=false" diff --git a/arkindex/documents/tests/test_destroy_worker_results.py b/arkindex/documents/tests/test_destroy_worker_results.py index 2aedc94152..ee5b35614b 100644 --- a/arkindex/documents/tests/test_destroy_worker_results.py +++ b/arkindex/documents/tests/test_destroy_worker_results.py @@ -43,9 +43,10 @@ class TestDestroyWorkerResults(FixtureAPITestCase): response = self.client.delete(reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_wrong_acl(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_wrong_acl(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:worker-delete-results", kwargs={"corpus": str(self.private_corpus.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual( @@ -66,7 +67,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_no_filter(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) @@ -85,7 +86,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_version(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?worker_version_id={self.version.id}", @@ -111,7 +112,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): are ignored """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + ( @@ -138,7 +139,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_worker_run(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?worker_run_id={self.worker_run.id}", @@ -160,7 +161,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_element(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?element_id={self.page.id}" @@ -182,7 +183,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_element_worker_run(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + ( @@ -207,7 +208,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_unset_configuration(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?configuration_id=false", @@ -229,7 +230,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_model_version(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?model_version_id={self.model_version.id}", @@ -251,7 +252,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.worker_results_delete.delay") def test_filter_element_worker_version_model_version_configuration(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + ( @@ -282,7 +283,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_invalid_version_id(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?worker_version_id=lol" @@ -295,7 +296,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_wrong_version_id(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?worker_version_id=12341234-1234-1234-1234-123412341234" @@ -308,7 +309,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_invalid_worker_run_id(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?worker_run_id=lol" @@ -321,7 +322,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_wrong_worker_run_id(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?worker_run_id=12341234-1234-1234-1234-123412341234" @@ -334,7 +335,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_invalid_element_id(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?element_id=lol" @@ -347,7 +348,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_wrong_element_id(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?element_id=12341234-1234-1234-1234-123412341234" @@ -360,7 +361,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_invalid_model_version_id(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?model_version_id=lol" @@ -373,7 +374,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_wrong_model_version_id(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?model_version_id=12341234-1234-1234-1234-123412341234" @@ -386,7 +387,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_invalid_configuration_id(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?configuration_id=true" @@ -399,7 +400,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_wrong_configuration_id(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?configuration_id=12341234-1234-1234-1234-123412341234" @@ -413,7 +414,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @override_settings(ARKINDEX_FEATURES={"selection": False}) def test_selection_feature_flag(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?use_selection=true" @@ -427,7 +428,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): @override_settings(ARKINDEX_FEATURES={"selection": True}) def test_selection_no_element_id(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?use_selection=true&element_id={self.page.id}" @@ -445,7 +446,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): def test_selection_empty(self): self.client.force_login(self.user) self.assertFalse(self.user.selected_elements.exists()) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?use_selection=true" @@ -478,7 +479,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): self.user.selected_elements.add(self.page) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + "?use_selection=true" @@ -502,7 +503,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): self.user.selected_elements.add(self.page) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?use_selection=true&worker_version_id={self.version.id}" @@ -527,7 +528,7 @@ class TestDestroyWorkerResults(FixtureAPITestCase): self.user.selected_elements.add(self.page) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:worker-delete-results", kwargs={"corpus": str(self.corpus.id)}) + f"?use_selection=true&worker_run_id={self.worker_run.id}" diff --git a/arkindex/documents/tests/test_edit_transcriptions.py b/arkindex/documents/tests/test_edit_transcriptions.py index a3341ab3de..c756ea5ddc 100644 --- a/arkindex/documents/tests/test_edit_transcriptions.py +++ b/arkindex/documents/tests/test_edit_transcriptions.py @@ -1,3 +1,4 @@ +from unittest.mock import patch from uuid import uuid4 from django.urls import reverse @@ -80,7 +81,8 @@ class TestEditTranscription(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_get_private(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_get_private(self, filter_rights_mock): user = User.objects.create_user("f@gh.ij", "b") user.verified_email = True user.save() @@ -183,7 +185,8 @@ class TestEditTranscription(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"orientation": ["Value is not of type TextOrientation"]}) - def test_patch_write_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_patch_write_right(self, has_access_mock): """ A write right is required to patch a manual transcription """ @@ -198,7 +201,8 @@ class TestEditTranscription(FixtureAPITestCase): "detail": "A write access to transcription element corpus is required." }) - def test_patch_admin_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_patch_admin_right(self, has_access_mock): """ Updating a transcription produced by a ML worker is forbidden """ @@ -281,7 +285,8 @@ class TestEditTranscription(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"orientation": ["Value is not of type TextOrientation"]}) - def test_put_write_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_put_write_right(self, has_access_mock): """ A write right is required to put a manual transcription """ @@ -296,7 +301,8 @@ class TestEditTranscription(FixtureAPITestCase): "detail": "A write access to transcription element corpus is required." }) - def test_put_admin_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_put_admin_right(self, has_access_mock): """ Updating a transcription produced by a ML worker is forbidden """ @@ -346,7 +352,8 @@ class TestEditTranscription(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(Transcription.objects.filter(id=self.manual_transcription.id).exists()) - def test_delete_manual_write_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_delete_manual_write_right(self, has_access_mock): """ Deleting a transcription without a write right is forbidden """ diff --git a/arkindex/documents/tests/test_element_paths_api.py b/arkindex/documents/tests/test_element_paths_api.py index 12667ba439..f71d1298e3 100644 --- a/arkindex/documents/tests/test_element_paths_api.py +++ b/arkindex/documents/tests/test_element_paths_api.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status @@ -41,7 +43,7 @@ class TestElementsAPI(FixtureAPITestCase): self.client.force_login(self.user) # Link desk to its parent room - with self.assertNumQueries(13): + with self.assertNumQueries(11): response = self.client.post(reverse("api:element-parent", kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -65,10 +67,13 @@ class TestElementsAPI(FixtureAPITestCase): response = self.client.post(reverse("api:element-parent", kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_create_acl(self, filter_rights_mock): self.client.force_login(self.user) self.desk.corpus = self.private_corpus self.desk.save() + filter_rights_mock.return_value = Corpus.objects.filter(id=self.corpus.id) + response = self.client.post(reverse("api:element-parent", kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -131,10 +136,13 @@ class TestElementsAPI(FixtureAPITestCase): response = self.client.delete(reverse("api:element-parent", kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_delete_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_delete_acl(self, filter_rights_mock): self.client.force_login(self.user) self.desk.corpus = self.private_corpus self.desk.save() + filter_rights_mock.return_value = Corpus.objects.filter(id=self.corpus.id) + response = self.client.delete(reverse("api:element-parent", kwargs=self.default_kwargs)) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( diff --git a/arkindex/documents/tests/test_element_type.py b/arkindex/documents/tests/test_element_type.py index bed5b21d49..37d2882b3c 100644 --- a/arkindex/documents/tests/test_element_type.py +++ b/arkindex/documents/tests/test_element_type.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -23,7 +25,8 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_no_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_requires_admin(self, filter_rights_mock): self.client.force_login(self.user) response = self.client.post(reverse("api:element-type-create"), { "corpus": self.private_corpus.id, @@ -32,15 +35,8 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_create_requires_admin(self): - self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - response = self.client.post(reverse("api:element-type-create"), { - "corpus": self.private_corpus.id, - "slug": "New_element_type", - "display_name": "New element type", - }, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) def test_create_wrong_slug(self): self.client.force_login(self.superuser) @@ -153,16 +149,8 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_no_access(self): - private_element_type = ElementType.objects.create(corpus=self.private_corpus, slug="element", display_name="Element") - self.client.force_login(self.user) - response = self.client.patch(reverse("api:element-type", kwargs={"pk": private_element_type.id}), { - "slug": "New_element_type", - "display_name": "New element type", - }, format="json") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_partial_update_requires_admin(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_partial_update_requires_admin(self, filter_rights_mock): private_element_type = ElementType.objects.create(corpus=self.private_corpus, slug="element", display_name="Element") self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) @@ -172,6 +160,9 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) + def test_partial_update_wrong_slug(self): element_type = ElementType.objects.create(corpus=self.corpus, slug="new_type", display_name="Element") self.client.force_login(self.superuser) @@ -254,16 +245,8 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_no_access(self): - private_element_type = ElementType.objects.create(corpus=self.private_corpus, slug="element", display_name="Element") - self.client.force_login(self.user) - response = self.client.put(reverse("api:element-type", kwargs={"pk": private_element_type.id}), { - "slug": "New_element_type", - "display_name": "New element type", - }, format="json") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_requires_admin(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_update_requires_admin(self, filter_rights_mock): private_element_type = ElementType.objects.create(corpus=self.private_corpus, slug="element", display_name="Element") self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) @@ -273,6 +256,9 @@ class TestElementType(FixtureAPITestCase): }, format="json") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) + def test_update_wrong_slug(self): element_type = ElementType.objects.create(corpus=self.corpus, slug="new_type", display_name="Element") self.client.force_login(self.superuser) @@ -341,13 +327,17 @@ class TestElementType(FixtureAPITestCase): response = self.client.delete(reverse("api:element-type", kwargs={"pk": self.element_type.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_delete_requires_admin(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_delete_requires_admin(self, filter_rights_mock): private_element_type = ElementType.objects.create(corpus=self.private_corpus, slug="element", display_name="Element") self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) response = self.client.delete(reverse("api:element-type", kwargs={"pk": private_element_type.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) + def test_delete(self): self.client.force_login(self.superuser) with self.assertNumQueries(11): diff --git a/arkindex/documents/tests/test_entities_api.py b/arkindex/documents/tests/test_entities_api.py index 39c4dde2fd..28dcd2b9f6 100644 --- a/arkindex/documents/tests/test_entities_api.py +++ b/arkindex/documents/tests/test_entities_api.py @@ -1,5 +1,7 @@ import uuid +from unittest.mock import call, patch +from django.contrib.auth.models import AnonymousUser from django.contrib.gis.geos import LinearRing from django.urls import reverse from rest_framework import status @@ -16,6 +18,7 @@ from arkindex.documents.models import ( ) from arkindex.process.models import ProcessMode, WorkerRun, WorkerVersion from arkindex.project.tests import FixtureAPITestCase +from arkindex.users.models import Role class TestEntitiesAPI(FixtureAPITestCase): @@ -98,7 +101,8 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_get_entity(self): with self.assertNumQueries(3): response = self.client.get(reverse("api:entity-details", kwargs={"pk": str(self.entity.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() self.assertEqual(data["id"], str(self.entity.id)) self.assertEqual(data["type"]["id"], str(self.entity.type.id)) @@ -108,21 +112,25 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_get_entity_elements(self): with self.assertNumQueries(6): response = self.client.get(reverse("api:entity-elements", kwargs={"pk": str(self.entity.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() results = data["results"] self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], str(self.element.id)) self.assertEqual(results[0]["name"], self.element.name) - def test_get_entity_elements_corpus_acl(self): - self.client.force_login(self.user) - self.element.corpus = self.private_corpus - self.element.save() - with self.assertNumQueries(5): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_get_entity_elements_corpus_acl(self, filter_rights_mock): + with self.assertNumQueries(0): response = self.client.get(reverse("api:entity-elements", kwargs={"pk": str(self.entity.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.json().get("results"), []) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(AnonymousUser(), Corpus, Role.Guest.value), + call(AnonymousUser(), Corpus, Role.Guest.value), + ]) def test_get_entity_elements_from_transcription(self): elt = self.corpus.elements.create( @@ -133,9 +141,11 @@ class TestEntitiesAPI(FixtureAPITestCase): ) elt_tr = elt.transcriptions.create(worker_version=self.worker_version_1, text="goodbye") TranscriptionEntity.objects.create(transcription=elt_tr, entity=self.entity, offset=42, length=7) + with self.assertNumQueries(6): response = self.client.get(reverse("api:entity-elements", kwargs={"pk": str(self.entity.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() results = data["results"] self.assertEqual(len(results), 2) @@ -200,7 +210,7 @@ class TestEntitiesAPI(FixtureAPITestCase): "child_type_id": self.location_type.id } self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(reverse("api:corpus-roles", kwargs={"pk": str(self.corpus.id)}), data=data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() @@ -219,7 +229,7 @@ class TestEntitiesAPI(FixtureAPITestCase): "child_type_id": self.role.child_type.id, } self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:corpus-roles", kwargs={"pk": str(self.corpus.id)}), data=data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = response.json() @@ -254,27 +264,6 @@ class TestEntitiesAPI(FixtureAPITestCase): response = self.client.post(reverse("api:corpus-roles", kwargs={"pk": str(self.corpus.id)}), data=data) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_role_no_corpus_rights(self): - self.client.force_login(self.user) - - private_corpus = Corpus.objects.create(name="private") - org_type = EntityType.objects.create(name="organization", corpus=private_corpus) - location_type = EntityType.objects.create(name="location", corpus=private_corpus) - data = { - "parent_name": "other parent", - "child_name": "other child", - "parent_type_id": org_type.id, - "child_type_id": location_type.id - } - with self.assertNumQueries(7): - response = self.client.post(reverse("api:corpus-roles", kwargs={"pk": str(private_corpus.id)}), data=data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - data = response.json() - self.assertEqual(data, { - "corpus": ["You do not have write access to this corpus"], - "id": [str(private_corpus.id)] - }) - def test_create_role_type_not_in_corpus(self): private_corpus = Corpus.objects.create(name="private") ext_type_1 = EntityType.objects.create(name="goose", corpus=private_corpus) @@ -286,7 +275,7 @@ class TestEntitiesAPI(FixtureAPITestCase): "parent_name": "nick", "child_name": "bradley" } - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:corpus-roles", kwargs={"pk": str(self.corpus.id)}), data=data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -305,7 +294,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"type_id": ["“location†is not a valid UUID."]}) @@ -321,7 +310,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"type_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.']}) @@ -338,7 +327,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"type_id": [f"EntityType {str(extraneous_type.id)} does not exist in corpus Unit Tests."]}) @@ -353,7 +342,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"type_id": ["This field is required."]}) @@ -387,7 +376,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), { @@ -406,7 +395,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"worker_run_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.']}) @@ -416,7 +405,7 @@ class TestEntitiesAPI(FixtureAPITestCase): A user can create an entity without a worker run """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:entity-create"), format="json", @@ -502,7 +491,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:entity-create"), format="json", @@ -536,7 +525,7 @@ class TestEntitiesAPI(FixtureAPITestCase): }, } self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post(reverse("api:entity-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -588,7 +577,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:entity-create"), format="json", @@ -623,7 +612,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:entity-create"), format="json", @@ -660,7 +649,7 @@ class TestEntitiesAPI(FixtureAPITestCase): "role": str(self.role.id) } self.client.force_login(self.user) - with self.assertNumQueries(16): + with self.assertNumQueries(14): response = self.client.post(reverse("api:entity-link-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) link = EntityLink.objects.get(id=response.json()["id"]) @@ -697,13 +686,13 @@ class TestEntitiesAPI(FixtureAPITestCase): "role": str(self.role.id) } self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(9): response = self.client.post(reverse("api:entity-link-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_create_transcription_entity(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=self.tr_entities_sample, @@ -730,7 +719,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_with_confidence(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=self.tr_entities_confidence_sample, @@ -767,7 +756,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_worker_version(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -788,7 +777,7 @@ class TestEntitiesAPI(FixtureAPITestCase): A regular user can create classifications with a WorkerRun of their own local process """ self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -813,7 +802,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_forbidden_version(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -831,7 +820,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_bad_worker_run(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -847,25 +836,33 @@ class TestEntitiesAPI(FixtureAPITestCase): "worker_run_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.'], }) - def test_create_transcription_entity_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_create_transcription_entity_wrong_acl(self, filter_rights_mock): self.client.force_login(self.user) self.element.corpus = self.private_corpus self.element.save() - with self.assertNumQueries(7): + filter_rights_mock.return_value = Corpus.objects.exclude(id=self.private_corpus.id) + + with self.assertNumQueries(5): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=self.tr_entities_sample, format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( response.json(), {"transcription": ["invalid UUID or Corpus write-access is forbidden"]} ) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Corpus, Role.Contributor.value), + call(self.user, Corpus, Role.Contributor.value), + ]) def test_create_transcription_entity_wrong_length(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -883,7 +880,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_wrong_high_confidence(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -902,7 +899,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_create_transcription_entity_wrong_low_confidence(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -919,7 +916,8 @@ class TestEntitiesAPI(FixtureAPITestCase): {"confidence": ["Ensure this value is greater than or equal to 0."]} ) - def test_create_transcription_entity_different_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_create_transcription_entity_different_corpus(self, filter_rights_mock): self.client.force_login(self.user) person_type = EntityType.objects.create(name="person", corpus=self.private_corpus) ent = Entity.objects.create( @@ -929,17 +927,24 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_version=self.worker_version_1, ) self.tr_entities_sample.update({"entity": ent.id}) - with self.assertNumQueries(6): + filter_rights_mock.return_value = Corpus.objects.exclude(id=self.private_corpus.id) + + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=self.tr_entities_sample, format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( response.json(), {"entity": [f'Invalid pk "{ent.id}" - object does not exist.']} ) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Corpus, Role.Contributor.value), + call(self.user, Corpus, Role.Contributor.value), + ]) def test_create_transcription_entity_duplicate(self): self.client.force_login(self.user) @@ -949,7 +954,7 @@ class TestEntitiesAPI(FixtureAPITestCase): offset=4, length=len(self.entity.name) ) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=self.tr_entities_sample, @@ -974,7 +979,7 @@ class TestEntitiesAPI(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -1051,7 +1056,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), format="json", @@ -1078,7 +1083,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), format="json", @@ -1113,7 +1118,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.worker_run_1.process.run() task = self.worker_run_1.process.tasks.first() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), format="json", @@ -1150,7 +1155,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_run=self.worker_run_1, worker_version=self.worker_version_1 ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data={ @@ -1177,7 +1182,7 @@ class TestEntitiesAPI(FixtureAPITestCase): self.client.force_login(self.user) data = self.tr_entities_sample del data["length"] - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:transcription-entity-create", kwargs={"pk": str(self.transcription.id)}), data=data, @@ -1189,7 +1194,8 @@ class TestEntitiesAPI(FixtureAPITestCase): response = self.client.get(reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_list_transcription_entities_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_transcription_entities_wrong_acl(self, filter_rights_mock): self.transcription.element.corpus = self.private_corpus self.transcription.element.save() TranscriptionEntity.objects.create( @@ -1199,12 +1205,17 @@ class TestEntitiesAPI(FixtureAPITestCase): length=len(self.entity.name) ) self.client.force_login(self.user) - response = self.client.get(reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.assertNumQueries(2): + response = self.client.get(reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) def test_list_transcription_entities(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -1357,7 +1368,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_version=self.worker_version_2 ) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"worker_version": str(self.worker_version_2.id)} @@ -1397,7 +1408,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_run=self.worker_run_2, ) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"worker_run": str(self.worker_run_2.id)} @@ -1438,7 +1449,7 @@ class TestEntitiesAPI(FixtureAPITestCase): length=len(self.entity.name) ) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"worker_version": False} @@ -1477,7 +1488,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_version=self.worker_version_2, ) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"entity_worker_version": str(self.worker_version_1.id)} @@ -1523,7 +1534,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_run=self.worker_run_2, ) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"entity_worker_run": str(self.worker_run_1.id)} @@ -1573,7 +1584,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_version=self.worker_version_2 ) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"entity_worker_version": False} @@ -1622,7 +1633,7 @@ class TestEntitiesAPI(FixtureAPITestCase): worker_version=self.worker_version_1 ) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.get( reverse("api:transcription-entities", kwargs={"pk": str(self.transcription.id)}), {"entity_worker_version": str(self.worker_version_1.id), "worker_version": str(self.worker_version_2.id)} @@ -1692,11 +1703,15 @@ class TestEntitiesAPI(FixtureAPITestCase): ] }) - def test_list_corpus_entities_acl(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_corpus_entities_acl(self, has_access_mock): with self.assertNumQueries(1): response = self.client.get(reverse("api:corpus-entities", kwargs={"pk": str(self.private_corpus.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(AnonymousUser(), self.private_corpus, Role.Guest.value, skip_public=False)) + def test_list_corpus_entities_parent(self): with self.assertNumQueries(4): response = self.client.get( @@ -1803,7 +1818,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_update_entity_requires_name_and_type(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.put( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), {"name": "a new name"}, @@ -1813,7 +1828,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_update_validate_entity(self): self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), { @@ -1829,7 +1844,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_update_unvalidate_entity(self): self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), { @@ -1899,7 +1914,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_update_entity_change_type(self): self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), { @@ -1918,7 +1933,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_partial_update_validate_entity(self): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), {"validated": True}, @@ -1930,7 +1945,7 @@ class TestEntitiesAPI(FixtureAPITestCase): def test_partial_update_unvalidate_entity(self): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:entity-details", kwargs={"pk": self.entity_bis.id}), {"validated": False}, @@ -1978,22 +1993,23 @@ class TestEntitiesAPI(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_element_links_not_guest(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_element_links_not_guest(self, has_access_mock): """ A guest access on the element is required to list entity links """ - private_elt = self.private_corpus.elements.create( - type=self.private_corpus.types.create(slug="page"), - name="A" - ) self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.get(reverse("api:element-links", kwargs={"pk": str(private_elt.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + with self.assertNumQueries(3): + response = self.client.get(reverse("api:element-links", kwargs={"pk": str(self.element.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual( response.json(), {"detail": "You do not have access to this element."} ) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Guest.value, skip_public=False)) def test_list_element_links(self): link = EntityLink.objects.create(parent=self.entity, child=self.entity_bis, role=self.role) diff --git a/arkindex/documents/tests/test_entity_types.py b/arkindex/documents/tests/test_entity_types.py index 7bbd833e8f..7f883e4eea 100644 --- a/arkindex/documents/tests/test_entity_types.py +++ b/arkindex/documents/tests/test_entity_types.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -31,7 +33,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): # ENTITY TYPE CREATE - def test_create_entity_type_requires_login(self): + def test_create_requires_login(self): data = { "corpus": str(self.corpus.id), "name": "dagger one" @@ -40,7 +42,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_entity_type_requires_verified(self): + def test_create_requires_verified(self): self.client.force_login(self.unverified_user) data = { "corpus": str(self.corpus.id), @@ -54,30 +56,36 @@ class TestEntityTypesAPI(FixtureAPITestCase): {"detail": "You do not have permission to perform this action."} ) - def test_create_entity_type_requires_corpus_admin(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_requires_corpus_admin(self, filter_rights_mock): self.client.force_login(self.user) data = { "corpus": str(self.private_corpus.id), "name": "dagger one" } self.assertEqual(self.private_corpus.entity_types.all().count(), 1) - with self.assertNumQueries(5): + + with self.assertNumQueries(2): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( response.json(), {"corpus": [f'Invalid pk "{str(self.private_corpus.id)}" - object does not exist.']} ) self.assertEqual(self.private_corpus.entity_types.all().count(), 1) - def test_create_entity_type(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) + + def test_create(self): self.client.force_login(self.private_corpus_admin) data = { "corpus": str(self.private_corpus.id), "name": "dagger spare" } self.assertEqual(self.private_corpus.entity_types.all().count(), 1) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(self.private_corpus.entity_types.all().count(), 2) @@ -85,7 +93,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): self.assertEqual(response.json()["name"], "dagger spare") self.assertEqual(response.json()["corpus"], str(self.private_corpus.id)) - def test_create_entity_type_color_validation(self): + def test_create_color_validation(self): self.client.force_login(self.private_corpus_admin) cases = [ [ @@ -105,7 +113,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): ] ] for color, errors in cases: - with (self.subTest(color=color, errors=errors), self.assertNumQueries(5)): + with (self.subTest(color=color, errors=errors), self.assertNumQueries(3)): response = self.client.post( reverse("api:entity-type-create"), data={ @@ -118,19 +126,19 @@ class TestEntityTypesAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"color": errors}) - def test_create_entity_type_color_ignore_case(self): + def test_create_color_ignore_case(self): self.client.force_login(self.private_corpus_admin) data = { "corpus": str(self.private_corpus.id), "name": "dagger spare", "color": "FF0000" } - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()["color"], "FF0000") - def test_create_entity_type_color(self): + def test_create_color(self): self.client.force_login(self.private_corpus_admin) data = { "corpus": str(self.private_corpus.id), @@ -138,7 +146,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): "color": "f76719" } self.assertEqual(self.private_corpus.entity_types.all().count(), 1) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(self.private_corpus.entity_types.all().count(), 2) @@ -146,21 +154,21 @@ class TestEntityTypesAPI(FixtureAPITestCase): self.assertEqual(response.json()["name"], "dagger spare") self.assertEqual(response.json()["corpus"], str(self.private_corpus.id)) - def test_create_entity_type_already_exists(self): + def test_create_already_exists(self): self.client.force_login(self.private_corpus_admin) data = { "corpus": str(self.private_corpus.id), "name": "some type", "color": "f76719" } - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:entity-type-create"), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"name": ["Entity Type with name some type already exists in corpus private."]}) # ENTITY TYPE UPDATE - def test_update_entity_type_requires_login(self): + def test_update_requires_login(self): data = { "name": "dagger one", "color": "f76719" @@ -169,7 +177,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_entity_type_requires_verified(self): + def test_update_requires_verified(self): self.client.force_login(self.unverified_user) data = { "name": "dagger one", @@ -183,15 +191,18 @@ class TestEntityTypesAPI(FixtureAPITestCase): {"detail": "You do not have permission to perform this action."} ) - def test_update_entity_type_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) data = { "name": "dagger one", "color": "f76719" } - with self.assertNumQueries(7): + + with self.assertNumQueries(4): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual( response.json(), {"detail": "You do not have admin access to this corpus."} @@ -200,27 +211,30 @@ class TestEntityTypesAPI(FixtureAPITestCase): self.assertEqual(self.private_corpus_entity_type.name, "some type") self.assertEqual(self.private_corpus_entity_type.color, "ff0000") - def test_update_entity_type(self): + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Admin.value, skip_public=False)) + + def test_update(self): self.client.force_login(self.private_corpus_admin) data = { "name": "dagger spare", "color": "f76719" } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.private_corpus_entity_type.refresh_from_db() self.assertEqual(self.private_corpus_entity_type.color, "f76719") self.assertEqual(self.private_corpus_entity_type.name, "dagger spare") - def test_update_entity_type_name_already_exists(self): + def test_update_name_already_exists(self): self.client.force_login(self.private_corpus_admin) EntityType.objects.create(corpus=self.private_corpus, name="dagger spare") data = { "name": "dagger spare", "color": "f76719" } - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"name": ["Entity Type with name dagger spare already exists in corpus private."]}) @@ -230,7 +244,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): data = { "color": "f76719" } - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"name": ["This field is required."]}) @@ -241,14 +255,14 @@ class TestEntityTypesAPI(FixtureAPITestCase): "corpus": str(self.corpus.id), "name": "iceman" } - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"corpus": ["It is not possible to update an Entity Type's corpus."]}) # ENTITY TYPE PARTIAL UPDATE - def test_partial_update_entity_type_requires_login(self): + def test_partial_update_requires_login(self): data = { "name": "dagger one", "color": "f76719" @@ -257,7 +271,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): response = self.client.patch(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_entity_type_requires_verified(self): + def test_partial_update_requires_verified(self): self.client.force_login(self.unverified_user) data = { "name": "dagger one", @@ -271,15 +285,18 @@ class TestEntityTypesAPI(FixtureAPITestCase): {"detail": "You do not have permission to perform this action."} ) - def test_partial_update_entity_type_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) data = { "name": "dagger one", "color": "f76719" } - with self.assertNumQueries(7): + + with self.assertNumQueries(4): response = self.client.patch(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual( response.json(), {"detail": "You do not have admin access to this corpus."} @@ -288,13 +305,16 @@ class TestEntityTypesAPI(FixtureAPITestCase): self.assertEqual(self.private_corpus_entity_type.name, "some type") self.assertEqual(self.private_corpus_entity_type.color, "ff0000") - def test_partial_update_entity_type(self): + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Admin.value, skip_public=False)) + + def test_partial_update(self): self.client.force_login(self.private_corpus_admin) data = { "name": "dagger spare", "color": "f76719" } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["color"], "f76719") @@ -305,7 +325,7 @@ class TestEntityTypesAPI(FixtureAPITestCase): data = { "color": "f76719" } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.private_corpus_entity_type.refresh_from_db() @@ -317,14 +337,14 @@ class TestEntityTypesAPI(FixtureAPITestCase): data = { "corpus": str(self.corpus.id), } - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"corpus": ["It is not possible to update an Entity Type's corpus."]}) # ENTITY TYPE RETRIEVE - def test_retrieve_entity_type_does_not_require_login_public_project(self): + def test_retrieve_does_not_require_login_public_project(self): with self.assertNumQueries(1): response = self.client.get(reverse("api:entity-type-details", kwargs={"pk": self.location_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -335,19 +355,25 @@ class TestEntityTypesAPI(FixtureAPITestCase): "color": "ff0000" }) - def test_retrieve_entity_type_requires_private_corpus_guest(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_retrieve_requires_private_corpus_guest(self, filter_rights_mock): self.client.force_login(self.no_access) - with self.assertNumQueries(5): + + with self.assertNumQueries(2): response = self.client.get(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertDictEqual( response.json(), {"detail": "Not found."} ) - def test_retrieve_entity_type(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.no_access, Corpus, Role.Guest.value)) + + def test_retrieve(self): self.client.force_login(self.private_corpus_guest) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["color"], "ff0000") @@ -355,9 +381,10 @@ class TestEntityTypesAPI(FixtureAPITestCase): # ENTITY TYPE DELETE - def test_delete_entity_type_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_delete_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual( @@ -365,23 +392,26 @@ class TestEntityTypesAPI(FixtureAPITestCase): {"detail": "You do not have admin access to this corpus."} ) - def test_delete_entity_type(self): + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Admin.value, skip_public=False)) + + def test_delete(self): self.client.force_login(self.private_corpus_admin) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.delete(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) with self.assertRaises(EntityType.DoesNotExist): self.private_corpus_entity_type.refresh_from_db() - def test_delete_entity_type_has_entities(self): + def test_delete_has_entities(self): Entity.objects.create(type=self.private_corpus_entity_type, corpus=self.private_corpus, name="bob") self.client.force_login(self.private_corpus_admin) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"detail": ["Some entities are using this entity type."]}) - def test_delete_entity_type_has_roles(self): + def test_delete_has_roles(self): EntityRole.objects.create( corpus=self.private_corpus, parent_name="goose", @@ -390,14 +420,14 @@ class TestEntityTypesAPI(FixtureAPITestCase): child_type=self.private_corpus_entity_type ) self.client.force_login(self.private_corpus_admin) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete(reverse("api:entity-type-details", kwargs={"pk": self.private_corpus_entity_type.id}), format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"detail": ["Some entity roles are using this entity type."]}) # LIST CORPUS ENTITY TYPES - def test_corpus_entity_types_does_not_require_login_public_project(self): + def test_list_does_not_require_login_public_project(self): with self.assertNumQueries(3): response = self.client.get(reverse("api:corpus-entity-types", kwargs={"pk": self.corpus.id}), format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -429,17 +459,21 @@ class TestEntityTypesAPI(FixtureAPITestCase): }, ]) - def test_corpus_entity_types_requires_guest_private_project(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_requires_guest_private_project(self, has_access_mock): self.client.force_login(self.no_access) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:corpus-entity-types", kwargs={"pk": self.private_corpus.id}), format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_corpus_entity_types(self): + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.no_access, self.private_corpus, Role.Guest.value, skip_public=False)) + + def test_list(self): type_2 = EntityType.objects.create(corpus=self.private_corpus, color="f01ef7", name="type 2") type_3 = EntityType.objects.create(corpus=self.private_corpus, color="29d911", name="person") self.client.force_login(self.private_corpus_guest) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:corpus-entity-types", kwargs={"pk": self.private_corpus.id}), format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertCountEqual(response.json()["results"], [ diff --git a/arkindex/documents/tests/test_export.py b/arkindex/documents/tests/test_export.py index 8016de5ee6..d1ab1e81d1 100644 --- a/arkindex/documents/tests/test_export.py +++ b/arkindex/documents/tests/test_export.py @@ -61,13 +61,16 @@ class TestExport(FixtureAPITestCase): self.assertFalse(delay_mock.called) @patch("arkindex.project.triggers.export.export_corpus.delay") - def test_start_requires_contributor(self, delay_mock): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_start_requires_contributor(self, has_access_mock, delay_mock): self.user.rights.update(level=Role.Guest.value) self.client.force_login(self.user) response = self.client.post(reverse("api:corpus-export", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Contributor.value, skip_public=False)) self.assertFalse(self.corpus.exports.exists()) self.assertFalse(delay_mock.called) @@ -146,14 +149,19 @@ class TestExport(FixtureAPITestCase): response = self.client.get(reverse("api:corpus-export", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_requires_guest(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_requires_guest(self, has_access_mock): self.user.rights.all().delete() self.corpus.public = False self.corpus.save() self.client.force_login(self.user) + response = self.client.get(reverse("api:corpus-export", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Guest.value, skip_public=False)) + @patch("arkindex.project.aws.s3.meta.client.generate_presigned_url") def test_download_export(self, presigned_url_mock): presigned_url_mock.return_value = "http://somewhere" @@ -182,7 +190,7 @@ class TestExport(FixtureAPITestCase): self.corpus.memberships.filter(user=self.user).delete() export = self.corpus.exports.create(user=self.superuser, state=CorpusExportState.Done) - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.client.get(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertEqual(response.headers["Location"], "http://somewhere") @@ -201,16 +209,20 @@ class TestExport(FixtureAPITestCase): response = self.client.get(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_download_export_requires_guest(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_download_export_requires_guest(self, filter_rights_mock): self.user.rights.all().delete() self.corpus.public = False self.corpus.save() self.client.force_login(self.user) export = self.corpus.exports.create(user=self.user, state=CorpusExportState.Done) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.get(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_download_export_not_done(self): self.client.force_login(self.superuser) for state in (CorpusExportState.Created, CorpusExportState.Running, CorpusExportState.Failed): @@ -236,14 +248,18 @@ class TestExport(FixtureAPITestCase): response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_delete_export_private_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_delete_export_private_corpus(self, filter_rights_mock): private_corpus = Corpus.objects.create(name="private") self.client.force_login(self.user) export = private_corpus.exports.create(user=self.superuser, state=CorpusExportState.Done) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_delete_export_wrong_state(self): self.client.force_login(self.superuser) for state in (CorpusExportState.Created, CorpusExportState.Failed): @@ -277,28 +293,52 @@ class TestExport(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) assert not self.corpus.exports.exists() - def test_delete_export_requires_rights(self): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_delete_export_requires_admin(self, get_max_level_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) export = self.corpus.exports.create(user=self.superuser, state=CorpusExportState.Done) - with self.assertNumQueries(5): + + with self.assertNumQueries(3): response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have sufficient rights to delete this export."}) - def test_delete_export_creator_contributor(self): + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + + @patch("arkindex.users.utils.get_max_level", return_value=Role.Guest.value) + def test_delete_export_creator_requires_contributor(self, get_max_level_mock): + self.client.force_login(self.user) + export = self.corpus.exports.create(user=self.user, state=CorpusExportState.Done) + + with self.assertNumQueries(3): + response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.assertDictEqual(response.json(), {"detail": "You do not have sufficient rights to delete this export."}) + + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_delete_export_creator_contributor(self, get_max_level_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) export = self.corpus.exports.create(user=self.user, state=CorpusExportState.Done) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) assert not self.corpus.exports.exists() + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + def test_delete_export_corpus_admin(self): self.client.force_login(self.user) export = self.corpus.exports.create(user=self.superuser, state=CorpusExportState.Done) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:manage-export", kwargs={"pk": export.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) assert not self.corpus.exports.exists() diff --git a/arkindex/documents/tests/test_metadata.py b/arkindex/documents/tests/test_metadata.py index a9a0ebe324..6968b3cbbf 100644 --- a/arkindex/documents/tests/test_metadata.py +++ b/arkindex/documents/tests/test_metadata.py @@ -1,5 +1,7 @@ import uuid +from unittest.mock import call, patch +from django.contrib.auth.models import AnonymousUser from django.urls import reverse from rest_framework import status @@ -54,7 +56,7 @@ class TestMetaData(FixtureAPITestCase): def test_list(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.json(), [ @@ -70,27 +72,14 @@ class TestMetaData(FixtureAPITestCase): }, ]) - def test_list_public(self): - self.assertTrue(self.corpus.public) - with self.assertNumQueries(2): - response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertListEqual(response.json(), [ - { - "id": str(self.metadata.id), - "name": "folio", - "type": "text", - "value": "123", - "dates": [], - "entity": None, - "worker_version": None, - "worker_run": None, - }, - ]) + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_private_requires_login(self, filter_rights_mock): + with self.assertNumQueries(0): + response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.private_vol.id)})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_list_private_requires_login(self): - response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.private_vol.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(AnonymousUser(), Corpus, Role.Guest.value)) def test_list_with_entity(self): # Link an entity to metadata, db requests count should remains same @@ -106,7 +95,7 @@ class TestMetaData(FixtureAPITestCase): entity=entity, ) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertCountEqual(response.json(), [ @@ -148,7 +137,7 @@ class TestMetaData(FixtureAPITestCase): self.metadata.worker_version = self.worker_version self.metadata.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.json(), [ @@ -170,7 +159,7 @@ class TestMetaData(FixtureAPITestCase): self.metadata.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -190,22 +179,26 @@ class TestMetaData(FixtureAPITestCase): } ]) - def test_list_wrong_acl(self): - self.vol.corpus = self.private_corpus - self.vol.save() + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_wrong_acl(self, filter_rights_mock): self.client.force_login(self.user) - response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + with self.assertNumQueries(2): + response = self.client.get(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) def test_list_wrong_element_id(self): self.client.force_login(self.user) - response = self.client.get(reverse("api:element-metadata", kwargs={"pk": "12341234-1234-1234-1234-123412341234"})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + with self.assertNumQueries(3): + response = self.client.get(reverse("api:element-metadata", kwargs={"pk": "12341234-1234-1234-1234-123412341234"})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_list_without_parents(self): self.vol.metadatas.create(type=MetaType.Numeric, name="weight", value="1337") self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:element-metadata", kwargs={"pk": str(self.page.id)}), {"load_parents": False}, @@ -230,7 +223,7 @@ class TestMetaData(FixtureAPITestCase): weight_meta = self.vol.metadatas.create(type=MetaType.Numeric, name="weight", value="1337.0") self.client.force_login(self.user) # One more request is required to list element paths - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:element-metadata", kwargs={"pk": str(self.page.id)}), {"load_parents": True}, @@ -298,17 +291,23 @@ class TestMetaData(FixtureAPITestCase): response = method(reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - def test_create_metadata_writable_element(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_metadata_writable_element(self, filter_rights_mock): self.client.force_login(self.user) hidden_book = self.private_corpus.elements.create( type=self.corpus.types.get(slug="volume"), name="Nope" ) - response = self.client.post( - reverse("api:element-metadata", kwargs={"pk": str(hidden_book.id)}), - data={"type": "text", "name": "language", "value": "Japanese"} - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.assertNumQueries(2): + response = self.client.post( + reverse("api:element-metadata", kwargs={"pk": str(hidden_book.id)}), + data={"type": "text", "name": "language", "value": "Japanese"} + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Contributor.value)) def test_create_metadata(self): self.client.force_login(self.user) @@ -322,7 +321,7 @@ class TestMetaData(FixtureAPITestCase): def test_create_metadata_worker_version(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={ @@ -339,7 +338,7 @@ class TestMetaData(FixtureAPITestCase): def test_create_metadata_worker_run_or_version(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={ @@ -418,7 +417,7 @@ class TestMetaData(FixtureAPITestCase): def test_create_duplicated_metadata(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={"type": "date", "name": "edition", "value": "1986-june"} @@ -504,7 +503,7 @@ class TestMetaData(FixtureAPITestCase): other_worker_run = process2.worker_runs.create(version=self.worker_run.version, parents=[]) self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={"type": "text", "name": "color", "value": "red", "worker_run_id": str(other_worker_run.id)}, @@ -522,7 +521,7 @@ class TestMetaData(FixtureAPITestCase): A regular user can create a metadata with a WorkerRun of their own local process """ self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={"type": "location", "name": "location", "value": "Texas", "worker_run_id": str(self.local_worker_run.id)} @@ -554,7 +553,7 @@ class TestMetaData(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(12): + with self.assertNumQueries(10): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={"type": "text", "name": "color", "value": "red", "worker_run_id": str(self.worker_run.id)}, @@ -578,7 +577,7 @@ class TestMetaData(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(12): + with self.assertNumQueries(10): response = self.client.post( reverse("api:element-metadata", kwargs={"pk": str(self.vol.id)}), data={"type": "text", "name": "color", "value": "red", "worker_run_id": str(local_worker_run.id)}, @@ -629,20 +628,27 @@ class TestMetaData(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - def test_delete_metadata_private_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_delete_metadata_private_corpus(self, filter_rights_mock): self.client.force_login(self.user) - response = self.client.delete(reverse("api:metadata-edit", kwargs={"pk": str(self.private_metadata.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + with self.assertNumQueries(2): + response = self.client.delete(reverse("api:metadata-edit", kwargs={"pk": str(self.private_metadata.id)})) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_delete_metadata_readable_element(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_delete_metadata_readable_element(self, has_access_mock): """ An explicit message should be raised when user can read but not delete metadata """ self.client.force_login(self.user) - self.private_corpus.memberships.create(user=self.user, level=Role.Guest.value) - response = self.client.delete(reverse("api:metadata-edit", kwargs={"pk": str(self.private_metadata.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + with self.assertNumQueries(3): + response = self.client.delete(reverse("api:metadata-edit", kwargs={"pk": str(self.private_metadata.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have write access to this corpus."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Contributor.value, skip_public=False)) def test_admin_create_any(self): """ @@ -993,7 +999,7 @@ class TestMetaData(FixtureAPITestCase): def test_get_metadata(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:metadata-edit", kwargs={"pk": str(self.metadata.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1019,7 +1025,7 @@ class TestMetaData(FixtureAPITestCase): self.metadata.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:metadata-edit", kwargs={"pk": str(self.metadata.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1043,7 +1049,7 @@ class TestMetaData(FixtureAPITestCase): self.metadata.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:metadata-edit", kwargs={"pk": str(self.metadata.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1305,21 +1311,27 @@ class TestMetaData(FixtureAPITestCase): response = method(reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - def test_bulk_create_metadata_writable_element(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_bulk_create_metadata_writable_element(self, filter_rights_mock): self.client.force_login(self.user) hidden_book = self.private_corpus.elements.create( type=self.corpus.types.get(slug="volume"), name="Nope" ) - response = self.client.post( - reverse("api:element-metadata-bulk", kwargs={"pk": str(hidden_book.id)}), - data={ - "worker_run_id": str(self.worker_run.id), - "metadata_list": [{"type": "text", "name": "language", "value": "Japanese"}], - }, - format="json" - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.assertNumQueries(2): + response = self.client.post( + reverse("api:element-metadata-bulk", kwargs={"pk": str(hidden_book.id)}), + data={ + "worker_run_id": str(self.worker_run.id), + "metadata_list": [{"type": "text", "name": "language", "value": "Japanese"}], + }, + format="json" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Contributor.value)) def test_bulk_create_metadata_local(self): """ @@ -1328,7 +1340,7 @@ class TestMetaData(FixtureAPITestCase): entity = self.corpus.entities.create(name="42", type=self.number_type) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={ @@ -1389,7 +1401,7 @@ class TestMetaData(FixtureAPITestCase): Worker run must be specified, worker version is forbidden """ self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={"metadata_list": [ @@ -1503,7 +1515,7 @@ class TestMetaData(FixtureAPITestCase): self.worker_run.process.run() task = self.worker_run.process.tasks.first() - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={ @@ -1519,7 +1531,7 @@ class TestMetaData(FixtureAPITestCase): def test_bulk_create_metadata_malicious_data(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={"metadata_list": [{ @@ -1602,7 +1614,7 @@ class TestMetaData(FixtureAPITestCase): """ self.vol.metadatas.all().delete() process_worker_run = self.process.worker_runs.get() - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={ @@ -1657,7 +1669,7 @@ class TestMetaData(FixtureAPITestCase): The metadata created with the bulk endpoint must be unique """ self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={ @@ -1709,7 +1721,7 @@ class TestMetaData(FixtureAPITestCase): local_worker_run = local_process.worker_runs.get() self.vol.metadatas.all().delete() - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:element-metadata-bulk", kwargs={"pk": str(self.vol.id)}), data={ diff --git a/arkindex/documents/tests/test_move_element.py b/arkindex/documents/tests/test_move_element.py index 9380575afc..d667bd311f 100644 --- a/arkindex/documents/tests/test_move_element.py +++ b/arkindex/documents/tests/test_move_element.py @@ -29,14 +29,15 @@ class TestMoveElement(FixtureAPITestCase): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": str(self.destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_move_element_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_move_element_wrong_acl(self, filter_rights_mock): private_corpus = Corpus.objects.create(name="private", public=False) private_element = private_corpus.elements.create( type=private_corpus.types.create(slug="folder"), ) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(2): response = self.client.post(reverse("api:move-element"), {"source": str(private_element.id), "destination": str(private_element.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -49,7 +50,7 @@ class TestMoveElement(FixtureAPITestCase): def test_move_element_wrong_source(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-element"), {"source": "12341234-1234-1234-1234-123412341234", "destination": str(self.destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -59,7 +60,7 @@ class TestMoveElement(FixtureAPITestCase): def test_move_element_wrong_destination(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": "12341234-1234-1234-1234-123412341234"}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -69,7 +70,7 @@ class TestMoveElement(FixtureAPITestCase): def test_move_element_same_source_destination(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": str(self.source.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -83,7 +84,7 @@ class TestMoveElement(FixtureAPITestCase): destination = corpus2.elements.create(type=corpus2.types.create(slug="folder")) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": str(destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -95,7 +96,7 @@ class TestMoveElement(FixtureAPITestCase): destination = self.corpus.elements.get(name="Volume 1") self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": str(destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -108,7 +109,7 @@ class TestMoveElement(FixtureAPITestCase): destination_id = self.source.id self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:move-element"), {"source": str(source.id), "destination": str(destination_id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -119,7 +120,7 @@ class TestMoveElement(FixtureAPITestCase): @patch("arkindex.project.triggers.documents_tasks.move_element.delay") def test_move_element(self, delay_mock): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:move-element"), {"source": str(self.source.id), "destination": str(self.destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/arkindex/documents/tests/test_move_selection.py b/arkindex/documents/tests/test_move_selection.py index 956449c7d5..d524373efe 100644 --- a/arkindex/documents/tests/test_move_selection.py +++ b/arkindex/documents/tests/test_move_selection.py @@ -35,7 +35,7 @@ class TestMoveSelection(FixtureAPITestCase): @override_settings(ARKINDEX_FEATURES={"selection": False}) def test_move_selection_disabled(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(self.destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -43,14 +43,15 @@ class TestMoveSelection(FixtureAPITestCase): ) @override_settings(ARKINDEX_FEATURES={"selection": True}) - def test_move_selection_wrong_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_move_selection_wrong_acl(self, filter_rights_mock): private_corpus = Corpus.objects.create(name="private", public=False) private_element = private_corpus.elements.create( type=private_corpus.types.create(slug="folder"), ) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(2): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(private_corpus.id), "destination": str(private_element.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -65,7 +66,7 @@ class TestMoveSelection(FixtureAPITestCase): def test_move_selection_wrong_destination(self): self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": "12341234-1234-1234-1234-123412341234"}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -77,7 +78,7 @@ class TestMoveSelection(FixtureAPITestCase): def test_move_selection_same_destination(self): self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(self.page.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -92,7 +93,7 @@ class TestMoveSelection(FixtureAPITestCase): destination = corpus2.elements.create(type=corpus2.types.create(slug="folder")) self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -105,7 +106,7 @@ class TestMoveSelection(FixtureAPITestCase): destination = self.corpus.elements.get(name="Volume 1") self.client.force_login(self.user) self.user.selected_elements.add(self.page) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -119,7 +120,7 @@ class TestMoveSelection(FixtureAPITestCase): destination_id = self.page.id self.client.force_login(self.user) self.user.selected_elements.add(target) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(destination_id)}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -134,7 +135,7 @@ class TestMoveSelection(FixtureAPITestCase): self.user.selected_elements.add(self.page) another_page = self.corpus.elements.get(name="Volume 1, page 1v") self.user.selected_elements.add(another_page) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post(reverse("api:move-selection"), {"corpus_id": str(self.corpus.id), "destination": str(self.destination.id)}, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/arkindex/documents/tests/test_neighbors.py b/arkindex/documents/tests/test_neighbors.py index 0268555882..01340642c3 100644 --- a/arkindex/documents/tests/test_neighbors.py +++ b/arkindex/documents/tests/test_neighbors.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.db import transaction from django.urls import reverse from rest_framework import status @@ -5,6 +7,7 @@ from rest_framework import status from arkindex.documents.models import Corpus, Element from arkindex.project.tests import FixtureAPITestCase from arkindex.project.tools import build_tree +from arkindex.users.models import Role class TestElementNeighbors(FixtureAPITestCase): @@ -15,7 +18,8 @@ class TestElementNeighbors(FixtureAPITestCase): cls.volume_type = cls.corpus.types.get(slug="volume") cls.page_type = cls.corpus.types.get(slug="page") - def test_element_neighbors_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_element_neighbors_acl(self, filter_rights_mock): """ A Guest access is required to list neighbors of an element """ @@ -31,10 +35,13 @@ class TestElementNeighbors(FixtureAPITestCase): type=private_type, ) self.client.force_login(self.user) - with self.assertNumQueries(6): + + with self.assertNumQueries(2): response = self.client.get(reverse("api:elements-neighbors", kwargs={"pk": str(elements["A"].id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), {"detail": "You do not have a read access to this element."}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) def test_element_neighbors(self): r""" diff --git a/arkindex/documents/tests/test_patch_elements.py b/arkindex/documents/tests/test_patch_elements.py index be3da68ab6..4a0bd1c6db 100644 --- a/arkindex/documents/tests/test_patch_elements.py +++ b/arkindex/documents/tests/test_patch_elements.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -55,7 +57,8 @@ class TestPatchElements(FixtureAPITestCase): {"detail": "You do not have permission to perform this action."} ) - def test_patch_no_write_access(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_patch_no_write_access(self, has_access_mock): # Create read_only corpus right self.private_corpus.memberships.create(user=self.user, level=Role.Guest.value) self.assertTrue(self.user.verified_email) @@ -71,14 +74,15 @@ class TestPatchElements(FixtureAPITestCase): {"detail": "You do not have write access to this element."} ) - def test_patch_element_no_read_access(self): + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Contributor.value, skip_public=False)) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_patch_element_no_read_access(self, filter_rights_mock): """ Check patching an element as anonymous user is not possible """ - ext_user = User.objects.create_user(email="ark@ark.net") - ext_user.verified_email = True - ext_user.save() - self.client.force_login(ext_user) + self.client.force_login(self.user) response = self.client.patch( reverse("api:element-retrieve", kwargs={"pk": str(self.private_elt.id)}), data={"name": "Untitled (2)"}, @@ -86,9 +90,12 @@ class TestPatchElements(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_patch_element(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)}), data={"name": "Untitled (2)"}, diff --git a/arkindex/documents/tests/test_put_elements.py b/arkindex/documents/tests/test_put_elements.py index 1fac8693c4..c4d42407cb 100644 --- a/arkindex/documents/tests/test_put_elements.py +++ b/arkindex/documents/tests/test_put_elements.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -9,7 +11,7 @@ from arkindex.project.tests import FixtureAPITestCase from arkindex.users.models import Role, User -class TestPatchElements(FixtureAPITestCase): +class TestPutElements(FixtureAPITestCase): @classmethod def setUpTestData(cls): @@ -48,9 +50,8 @@ class TestPatchElements(FixtureAPITestCase): {"detail": "You do not have permission to perform this action."} ) - def test_put_no_write_access(self): - # Create read_only corpus right - self.private_corpus.memberships.create(user=self.user, level=Role.Guest.value) + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_put_no_write_access(self, has_access_mock): self.assertTrue(self.user.verified_email) self.client.force_login(self.user) response = self.client.put( @@ -63,15 +64,15 @@ class TestPatchElements(FixtureAPITestCase): response.json(), {"detail": "You do not have write access to this element."} ) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Contributor.value, skip_public=False)) - def test_put_element_no_read_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_put_element_no_read_access(self, filter_rights_mock): """ Check putting an element as anonymous user is not possible """ - ext_user = User.objects.create_user(email="ark@ark.net") - ext_user.verified_email = True - ext_user.save() - self.client.force_login(ext_user) + self.client.force_login(self.user) response = self.client.put( reverse("api:element-retrieve", kwargs={"pk": str(self.private_elt.id)}), data={"name": "Untitled (2)"}, @@ -79,11 +80,14 @@ class TestPatchElements(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_put_element(self): self.client.force_login(self.user) self.assertEqual(self.vol.name, "Volume 1") self.assertEqual(self.vol.type, self.volume_type) - with self.assertNumQueries(11): + with self.assertNumQueries(7): response = self.client.put( reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)}), data={"name": "Untitled (2)", "type": "text_line"}, diff --git a/arkindex/documents/tests/test_retrieve_elements.py b/arkindex/documents/tests/test_retrieve_elements.py index 326d1134f2..c8062471c5 100644 --- a/arkindex/documents/tests/test_retrieve_elements.py +++ b/arkindex/documents/tests/test_retrieve_elements.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -49,7 +51,7 @@ class TestRetrieveElements(FixtureAPITestCase): "mirrored": False, "created": "2020-02-02T01:23:45.678000Z", "creator": None, - "rights": ["read"], + "rights": ["read", "write", "admin"], "metadata_count": 0, "classifications": [ { @@ -175,7 +177,8 @@ class TestRetrieveElements(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["creator"], "Test user") - def test_get_element_rights(self): + @patch("arkindex.documents.serializers.elements.get_max_level") + def test_get_element_rights(self, get_max_level_mock): cases = [ (Role.Guest, self.superuser, ["read"]), (Role.Guest, self.user, ["read"]), @@ -187,12 +190,15 @@ class TestRetrieveElements(FixtureAPITestCase): self.client.force_login(self.user) for role, creator, expected_rights in cases: with self.subTest(role=role, creator=creator): + get_max_level_mock.return_value = role.value self.corpus.memberships.filter(user=self.user).update(level=role.value) self.vol.creator = creator self.vol.save() - with self.assertNumQueries(6): + + with self.assertNumQueries(4): response = self.client.get(reverse("api:element-retrieve", kwargs={"pk": str(self.vol.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.json()["rights"], expected_rights) def test_get_element_created_by_worker_run(self): @@ -222,7 +228,7 @@ class TestRetrieveElements(FixtureAPITestCase): "mirrored": False, "created": "2020-02-02T01:23:45.678000Z", "creator": None, - "rights": ["read"], + "rights": ["read", "write", "admin"], "metadata_count": 0, "classifications": [], "worker_run": { @@ -257,7 +263,7 @@ class TestRetrieveElements(FixtureAPITestCase): "mirrored": False, "created": "2020-02-02T01:23:45.678000Z", "creator": None, - "rights": ["read"], + "rights": ["read", "write", "admin"], "metadata_count": 0, "classifications": [ { diff --git a/arkindex/documents/tests/test_search_api.py b/arkindex/documents/tests/test_search_api.py index 53ac2d7499..2fe56cd2fe 100644 --- a/arkindex/documents/tests/test_search_api.py +++ b/arkindex/documents/tests/test_search_api.py @@ -1,5 +1,6 @@ from unittest.mock import call, patch +from django.contrib.auth.models import AnonymousUser from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -70,10 +71,13 @@ class TestSearchApi(FixtureAPITestCase): self.assertEqual(response.json(), {"detail": "Not found."}) @override_settings(ARKINDEX_FEATURES={"search": True}) - def test_corpus_no_permission(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_corpus_no_permission(self, has_access_mock): private_corpus = Corpus.objects.create(name="private", public=False) response = self.client.get(reverse("api:corpus-search", kwargs={"pk": private_corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(AnonymousUser(), private_corpus, Role.Guest.value, skip_public=False)) @override_settings(ARKINDEX_FEATURES={"search": True}) def test_corpus_not_indexable(self): @@ -331,13 +335,17 @@ class TestSearchApi(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @override_settings(ARKINDEX_FEATURES={"search": True}) - def test_build_index_requires_corpus_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_build_index_requires_corpus_admin(self, has_access_mock): self.corpus.memberships.update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post(reverse("api:build-search-index", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Admin.value, skip_public=False)) + @override_settings(ARKINDEX_FEATURES={"search": False}) @patch("arkindex.documents.tasks.reindex_corpus.delay") def test_build_index_requires_search_feature(self, reindex_trigger_mock): @@ -358,7 +366,7 @@ class TestSearchApi(FixtureAPITestCase): existing_job.ended_at = None job_mock.fetch.return_value = existing_job self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post(reverse("api:build-search-index", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -373,7 +381,7 @@ class TestSearchApi(FixtureAPITestCase): self.corpus.indexable = False self.corpus.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:build-search-index", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -387,7 +395,7 @@ class TestSearchApi(FixtureAPITestCase): def test_build_index_non_indexable_element_types(self, job_mock, reindex_trigger_mock): self.corpus.types.update(indexable=False) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post(reverse("api:build-search-index", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -404,7 +412,7 @@ class TestSearchApi(FixtureAPITestCase): existing_job.ended_at = "2000-01-01" job_mock.fetch.return_value = existing_job self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post(reverse("api:build-search-index", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertDictEqual(response.json(), {"drop": True}) @@ -424,7 +432,7 @@ class TestSearchApi(FixtureAPITestCase): def test_build_index(self, job_mock, reindex_trigger_mock): job_mock.fetch.side_effect = NoSuchJobError self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:build-search-index", kwargs={"pk": self.corpus.id}), data={"drop": True}, @@ -445,7 +453,7 @@ class TestSearchApi(FixtureAPITestCase): def test_build_index_drop_false(self, job_mock, reindex_trigger_mock): job_mock.fetch.side_effect = NoSuchJobError self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:build-search-index", kwargs={"pk": self.corpus.id}), data={"drop": False}, diff --git a/arkindex/documents/tests/test_selection_api.py b/arkindex/documents/tests/test_selection_api.py index d45eb8e847..36970acf11 100644 --- a/arkindex/documents/tests/test_selection_api.py +++ b/arkindex/documents/tests/test_selection_api.py @@ -1,5 +1,5 @@ - import uuid +from unittest.mock import call, patch from django.test import override_settings from django.urls import reverse @@ -10,7 +10,7 @@ from arkindex.project.tests import FixtureAPITestCase from arkindex.users.models import Role, User -class TestElementsAPI(FixtureAPITestCase): +class TestSelectionAPI(FixtureAPITestCase): @classmethod def setUpTestData(cls): @@ -55,7 +55,8 @@ class TestElementsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertListEqual(response.json(), ["Selection is not available on this instance."]) - def test_select_element_wrong_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_select_element_wrong_corpus(self, filter_rights_mock): user = User.objects.create_user("nope@nope.fr") user.verified_email = True user.save() @@ -74,6 +75,8 @@ class TestElementsAPI(FixtureAPITestCase): self.assertDictEqual(response.json(), { "ids": ["Some element IDs do not exist or you do not have permission to access them."], }) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(user, Corpus, Role.Guest.value)) def test_select_element_wrong_id(self): self.client.force_login(self.user) @@ -231,7 +234,7 @@ class TestElementsAPI(FixtureAPITestCase): self.user.selected_elements.add(self.page, self.vol, self.private_page) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:elements-selection"), data={"corpus": self.private_corpus.id}) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) response = self.client.get(reverse("api:elements-selection")) @@ -246,7 +249,7 @@ class TestElementsAPI(FixtureAPITestCase): self.user.selected_elements.add(self.page, self.vol) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:elements-selection"), data={"corpus": bad_id}) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) @@ -345,7 +348,7 @@ class TestElementsAPI(FixtureAPITestCase): self.user.selected_elements.add(self.page, self.vol, self.private_page) self.private_corpus.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(5): response = self.client.get(reverse("api:elements-selection"), data={"corpus": self.private_corpus.id}) self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.json()["results"] @@ -356,7 +359,7 @@ class TestElementsAPI(FixtureAPITestCase): self.user.selected_elements.add(self.page, self.vol) bad_id = uuid.uuid4() self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:elements-selection"), data={"corpus": bad_id}) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) diff --git a/arkindex/documents/tests/test_transcriptions.py b/arkindex/documents/tests/test_transcriptions.py index cda2b19db5..618f9aa9ee 100644 --- a/arkindex/documents/tests/test_transcriptions.py +++ b/arkindex/documents/tests/test_transcriptions.py @@ -1,10 +1,12 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status from arkindex.documents.models import Corpus, TextOrientation from arkindex.process.models import ProcessMode, WorkerRun, WorkerVersion from arkindex.project.tests import FixtureAPITestCase -from arkindex.users.models import User +from arkindex.users.models import Role, User class TestTranscriptions(FixtureAPITestCase): @@ -28,13 +30,17 @@ class TestTranscriptions(FixtureAPITestCase): cls.worker_version_2 = WorkerVersion.objects.get(worker__slug="dla") cls.worker_run = WorkerRun.objects.filter(version=cls.worker_version_1, process__mode=ProcessMode.Workers).get() - def test_list_transcriptions_read_right(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_transcriptions_read_right(self, filter_rights_mock): # A read right on the element corpus is required to access transcriptions self.client.force_login(self.private_read_user) - url = reverse("api:element-transcriptions", kwargs={"pk": str(self.private_page.id)}) - response = self.client.get(url) + + response = self.client.get(reverse("api:element-transcriptions", kwargs={"pk": str(self.private_page.id)})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.private_read_user, Corpus, Role.Guest.value)) + def test_list_element_transcriptions(self): tr1 = self.page.transcriptions.get() tr2 = self.page.transcriptions.create( @@ -44,7 +50,7 @@ class TestTranscriptions(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -72,7 +78,7 @@ class TestTranscriptions(FixtureAPITestCase): def test_list_transcriptions_recursive(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true"} @@ -94,7 +100,7 @@ class TestTranscriptions(FixtureAPITestCase): def test_list_transcriptions_recursive_filter_element_type(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "element_type": "page"} @@ -119,7 +125,7 @@ class TestTranscriptions(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_version": str(self.worker_version_2.id)} @@ -156,7 +162,7 @@ class TestTranscriptions(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_version": False} @@ -187,7 +193,7 @@ class TestTranscriptions(FixtureAPITestCase): def test_list_transcriptions_worker_version_validation(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_version": "oh no"} @@ -197,7 +203,7 @@ class TestTranscriptions(FixtureAPITestCase): def test_list_transcriptions_worker_run_validation(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_run": "oh no"} @@ -215,7 +221,7 @@ class TestTranscriptions(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_version": str(self.worker_version_2.id)} @@ -257,7 +263,7 @@ class TestTranscriptions(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={"recursive": "true", "worker_run": str(self.worker_run.id)} @@ -299,7 +305,7 @@ class TestTranscriptions(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:element-transcriptions", kwargs={"pk": str(self.page.id)}), data={ diff --git a/arkindex/images/tests/test_image_elements.py b/arkindex/images/tests/test_image_elements.py index 86459ccc93..2f1cb8a2a2 100644 --- a/arkindex/images/tests/test_image_elements.py +++ b/arkindex/images/tests/test_image_elements.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status @@ -14,7 +16,7 @@ class TestImageElements(FixtureTestCase): def test_image_elements(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -28,7 +30,8 @@ class TestImageElements(FixtureTestCase): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_image_elements_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_image_elements_acl(self, filter_rights_mock): """ A user cannot list elements on corpus they have no guest access """ @@ -41,9 +44,12 @@ class TestImageElements(FixtureTestCase): image=self.img1, polygon=[(100, 100), (142, 142), (133, 337), (100, 100)] ) - with self.assertNumQueries(10): + filter_rights_mock.return_value = Corpus.objects.filter(id=self.corpus.id) + + with self.assertNumQueries(8): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.json()["results"] self.assertEqual(len(results), 7) response_names = [e["name"] for e in results] @@ -61,7 +67,7 @@ class TestImageElements(FixtureTestCase): polygon=[(0, 0), (42, 42), (13, 37), (0, 0)], ) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id}) + "?type=cake") self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -79,7 +85,7 @@ class TestImageElements(FixtureTestCase): vol.polygon = [(0, 0), (0, 1), (1, 1), (0, 0)] vol.save() - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id}) + "?folder") self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -103,7 +109,7 @@ class TestImageElements(FixtureTestCase): polygon=[(i, i), (i, 200), (200, 200), (200, i), (i, i)] ) for i in range(40) ]) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.get(reverse("api:image-elements", kwargs={"pk": self.img1.id}), {"type": "duplicated"}) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() diff --git a/arkindex/ponos/admin.py b/arkindex/ponos/admin.py index 474b809baa..d2210a4668 100644 --- a/arkindex/ponos/admin.py +++ b/arkindex/ponos/admin.py @@ -1,11 +1,7 @@ -from django import forms -from django.contrib import admin, messages -from django.core.exceptions import ValidationError +from django.contrib import admin from enumfields.admin import EnumFieldListFilter -from arkindex.ponos.keys import gen_nonce -from arkindex.ponos.models import FINAL_STATES, GPU, Agent, Artifact, Farm, Secret, Task, encrypt -from arkindex.users.admin import GroupMembershipInline, UserMembershipInline +from arkindex.ponos.models import Artifact, Task class ArtifactInline(admin.TabularInline): @@ -33,11 +29,9 @@ class TaskAdmin(admin.ModelAdmin): "state", "process_id", "updated", - "agent", "priority", ) - list_filter = [("state", EnumFieldListFilter), "agent"] - list_select_related = ("agent",) + list_filter = [("state", EnumFieldListFilter)] inlines = [ ArtifactInline, ] @@ -59,12 +53,10 @@ class TaskAdmin(admin.ModelAdmin): "depth", "state", "process", - "agent", "priority", ), }, ), - ("GPU", {"fields": ("requires_gpu", "gpu")}), ("Dates", {"fields": ("created", "updated", "expiry")}), ( "Docker", @@ -89,153 +81,4 @@ class TaskInline(admin.TabularInline): extra = 0 -class GPUInline(admin.TabularInline): - model = GPU - fields = ("id", "name", "index", "ram_total") - readonly_fields = fields - extra = 0 - - -class AgentAdmin(admin.ModelAdmin): - model = Agent - list_display = ("id", "hostname") - inlines = (GPUInline,) - readonly_fields = ( - "id", - "created", - "updated", - "last_ping", - # Use custom admin fields to format total RAM and CPU max frequency - "ram_total_human", - "cpu_frequency_human", - "cpu_load", - "ram_load", - "cpu_cores", - "public_key", - ) - fieldsets = ( - ( - None, - {"fields": ("id", "farm", "hostname")}, - ), - ("Dates", {"fields": ("created", "updated", "last_ping")}), - ("Auth", {"fields": ("public_key",)}), - ( - "Hardware", - { - "fields": ( - "ram_total_human", - "cpu_frequency_human", - "cpu_cores", - "cpu_load", - "ram_load", - ) - }, - ), - ) - - def has_add_permission(self, request): - return False - - def ram_total_human(self, instance): - """Returns total amount of RAM expressed in GiB""" - return "{:.1f} GiB".format((instance.ram_total or 0) / (1024**3)) - - def cpu_frequency_human(self, instance): - """Returns CPU max frequency expressed in GHz""" - return "{:.1f} GHz".format((instance.cpu_frequency or 0) / 1e9) - - # Overrides both admin deletion methods to show error messages when agents - # have tasks in non-final states. This will display a "deleted successfully" - # message along the errors, because the Django admin does not make it easy to - # remove this message, but nothing gets actually deleted. - - def delete_model(self, request, agent): - try: - super().delete_model(request, agent) - except ValidationError as e: - messages.error(request, e.message) - - def delete_queryset(self, request, queryset): - hostnames = ( - Task.objects.filter(agent__in=queryset) - .exclude(state__in=FINAL_STATES) - .values_list("agent__hostname", flat=True) - .distinct() - ) - if hostnames: - messages.error( - request, - "The following agents have tasks in non-final states and cannot be deleted: {}".format( - ", ".join(list(hostnames)) - ), - ) - else: - super().delete_queryset(request, queryset) - - -class FarmAdmin(admin.ModelAdmin): - model = Farm - list_display = ("id", "name") - fields = ("id", "name", "seed") - readonly_fields = ("id",) - inlines = [UserMembershipInline, GroupMembershipInline] - - -class ClearTextSecretForm(forms.ModelForm): - """ - Allow an administrator to edit a secret content as a cleartext value - A nonce is generated for newly created secrets - """ - - content = forms.CharField(widget=forms.Textarea, required=True) - - def __init__(self, *args, **kwargs): - self.instance = kwargs.get("instance") - # Set initial decrypted value - if self.instance is not None: - if "initial" not in kwargs: - kwargs["initial"] = {} - kwargs["initial"]["content"] = self.instance.decrypt() - - super().__init__(*args, **kwargs) - - def clean_name(self): - # Check that name is not already used - secrets = Secret.objects.all() - if self.instance: - secrets = Secret.objects.exclude(pk=self.instance.pk) - - if secrets.filter(name=self.cleaned_data["name"]).exists(): - raise ValidationError("A secret with this name already exists.") - - return self.cleaned_data["name"] - - def clean(self): - if not self.cleaned_data.get("content"): - raise ValidationError("You must specify some content") - - if self.instance: - nonce = self.instance.nonce - else: - nonce = gen_nonce() - - # Encrypt secret content - encrypted_content = encrypt(nonce, self.cleaned_data["content"]) - return {**self.cleaned_data, "nonce": nonce, "content": encrypted_content} - - class Meta: - model = Secret - fields = ("id", "name", "content") - - -class SecretAdmin(admin.ModelAdmin): - form = ClearTextSecretForm - - fields = ("name", "content") - - admin.site.register(Task, TaskAdmin) -admin.site.register(Agent, AgentAdmin) -admin.site.register(Farm, FarmAdmin) -admin.site.register(Secret, SecretAdmin) diff --git a/arkindex/ponos/api.py b/arkindex/ponos/api.py index 1d41b93ff3..5f4a225572 100644 --- a/arkindex/ponos/api.py +++ b/arkindex/ponos/api.py @@ -1,88 +1,20 @@ -import hashlib -import uuid -from collections import defaultdict from textwrap import dedent -from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone -from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema, extend_schema_view +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework.authentication import SessionAuthentication, TokenAuthentication -from rest_framework.exceptions import ValidationError -from rest_framework.generics import ( - CreateAPIView, - ListAPIView, - ListCreateAPIView, - RetrieveAPIView, - RetrieveUpdateAPIView, - UpdateAPIView, -) -from rest_framework.response import Response +from rest_framework.generics import ListCreateAPIView, RetrieveUpdateAPIView, UpdateAPIView from rest_framework.views import APIView -from arkindex.ponos.authentication import AgentAuthentication, TaskAuthentication -from arkindex.ponos.keys import load_private_key -from arkindex.ponos.models import Agent, Artifact, Farm, Secret, State, Task +from arkindex.ponos.models import Artifact, Task from arkindex.ponos.permissions import ( - IsAgent, IsAgentOrArtifactGuest, IsAgentOrTaskGuest, IsAssignedAgentOrReadOnly, IsAssignedAgentOrTaskOrReadOnly, - IsTask, IsTaskAdmin, ) -from arkindex.ponos.renderers import PublicKeyPEMRenderer -from arkindex.ponos.serializers import ( - AgentActionsSerializer, - AgentCreateSerializer, - AgentDetailsSerializer, - AgentLightSerializer, - AgentStateSerializer, - ArtifactSerializer, - ClearTextSecretSerializer, - FarmSerializer, - NewTaskSerializer, - TaskDefinitionSerializer, - TaskSerializer, - TaskTinySerializer, -) -from arkindex.project.permissions import IsVerified -from rest_framework_simplejwt.views import TokenRefreshView - - -class PublicKeyEndpoint(APIView): - """ - Fetch the server's public key in PEM format to perform agent registration. - """ - - authentication_classes = () - renderer_classes = (PublicKeyPEMRenderer,) - - @extend_schema( - operation_id="GetPublicKey", - responses={200: {"type": "string"}}, - tags=["ponos"], - examples=[ - OpenApiExample( - name="Public key response", - response_only=True, - media_type="application/x-pem-file", - status_codes=["200"], - value=dedent( - """ - -----BEGIN PUBLIC KEY----- - MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEmK2L6lwGzSVZwFSo0eR1z4XV6jJwjeWK - YCiPKdMcQnn6u5J016k9U8xZm6XyFnmgvkhnC3wreGBTFzwLCLZCD+F3vo5x8ivz - aTgNWsA3WFlqjSIEGz+PAVHSNMobBaJm - -----END PUBLIC KEY----- - """ - ), - ) - ], - ) - def get(self, request, *args, **kwargs): - return Response(load_private_key().public_key()) +from arkindex.ponos.serializers import ArtifactSerializer, TaskSerializer, TaskTinySerializer @extend_schema(tags=["ponos"]) @@ -117,19 +49,12 @@ class TaskDetailsFromAgent(RetrieveUpdateAPIView): # Avoid stale read when a recently assigned agent wants to update # the state of one of its tasks queryset = Task.objects.using("default").select_related( - # Serialized in responses "agent__farm", "gpu", # Used for permission checks "process__corpus", "process__revision__repo", ) - authentication_classes = ( - TaskAuthentication, - AgentAuthentication, - TokenAuthentication, - SessionAuthentication, - ) permission_classes = ( # On all HTTP methods, require either any Ponos agent, an instance admin, the task itself, or guest access to the process' task IsAgentOrTaskGuest, @@ -139,161 +64,6 @@ class TaskDetailsFromAgent(RetrieveUpdateAPIView): serializer_class = TaskSerializer -@extend_schema_view( - post=extend_schema( - operation_id="CreateAgent", - tags=["ponos"], - ), -) -class AgentRegister(CreateAPIView): - """ - Perform agent registration and authentication. - """ - authentication_classes = () - serializer_class = AgentCreateSerializer - - def get_object(self): - if not hasattr(self.request, "data") or "public_key" not in self.request.data: - return - key_hash = uuid.UUID( - hashlib.md5(self.request.data["public_key"].encode("utf-8")).hexdigest() - ) - return Agent.objects.filter(id=key_hash).first() - - def get_serializer(self, *args, **kwargs): - return super().get_serializer(self.get_object(), *args, **kwargs) - - -@extend_schema_view( - post=extend_schema( - operation_id="RefreshAgentToken", - tags=["ponos"], - ) -) -class AgentTokenRefresh(TokenRefreshView): - """ - Refresh a Ponos agent token when it expires - """ - - -@extend_schema(tags=["ponos"]) -class AgentDetails(RetrieveAPIView): - """ - Retrieve details of an agent including its running tasks - - Requires authentication with a verified e-mail address. Cannot be used with Ponos agent or task authentication. - """ - authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (IsVerified, ) - serializer_class = AgentDetailsSerializer - queryset = Agent.objects.select_related("farm") - - -@extend_schema( - description="List the state of all Ponos agents", - tags=["ponos"], -) -class AgentsState(ListAPIView): - """ - List all agents on the system with their health state. - - Requires authentication with a verified e-mail address. Cannot be used with Ponos agent or task authentication. - """ - authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (IsVerified, ) - serializer_class = AgentStateSerializer - - queryset = ( - Agent.objects.all() - .annotate( - running_tasks_count=Count("tasks", filter=Q(tasks__state=State.Running)) - ) - .prefetch_related("farm") - .order_by("hostname") - ) - - -@extend_schema( - tags=["ponos"], - parameters=[ - OpenApiParameter( - "cpu_load", - type=float, - description="Current CPU usage for this agent", - required=True - ), - OpenApiParameter( - "ram_load", - type=float, - description="Current RAM usage for this agent", - required=True - ), - ] -) -class AgentActions(RetrieveAPIView): - """ - Fetch the next actions an agent should perform. - - Requires authentication as a Ponos agent. - """ - - permission_classes = (IsAgent,) - authentication_classes = (AgentAuthentication,) - serializer_class = AgentActionsSerializer - - def get_object(self): - return self.request.user - - def retrieve(self, request, *args, **kwargs): - # Update agent load and last_ping timestamp - errors = defaultdict(list) - cpu_load = request.query_params.get("cpu_load") - ram_load = request.query_params.get("ram_load") - if not cpu_load: - errors["cpu_load"].append("This query parameter is required.") - if not ram_load: - errors["ram_load"].append("This query parameter is required.") - if errors: - raise ValidationError(errors) - # Handle fields validation with DRF as for a PATCH - agent_serializer = AgentLightSerializer( - self.request.user, - data={"cpu_load": cpu_load, "ram_load": ram_load}, - partial=True, - ) - agent_serializer.is_valid(raise_exception=True) - # Update agent load and last_ping attributes - agent_serializer.save(last_ping=timezone.now()) - # Retrieve next tasks after the DB has been updated with the new agent load - response = super().retrieve(self, request, *args, **kwargs) - return response - - -@extend_schema_view( - get=extend_schema( - operation_id="RetrieveTaskDefinition", - tags=["ponos"], - ) -) -class TaskDefinition(RetrieveAPIView): - """ - Obtain a task's definition. - This holds all the required data to start a task, except for the artifacts. - - Requires authentication as a Ponos agent. - """ - - # We need to specify the default database to avoid stale reads - # when a task is updated by an agent, then the agent immediately fetches its definition - queryset = Task.objects.using("default").select_related("process") - serializer_class = TaskDefinitionSerializer - - # This cannot be restricted to only the agent assigned to the task because agents need to access - # the parent tasks of an assigned task to download their artifacts. - authentication_classes = (AgentAuthentication, ) - permission_classes = (IsAgent, ) - - @extend_schema(tags=["ponos"]) @extend_schema_view( get=extend_schema( @@ -373,24 +143,6 @@ class TaskArtifactDownload(APIView): return redirect(artifact.s3_url) -@extend_schema_view( - post=extend_schema( - operation_id="CreateTask", - tags=["ponos"], - ) -) -class TaskCreate(CreateAPIView): - """ - Create a task that depends on an existing task. - - Requires authentication as a Ponos task. Tasks can only be created on the process of the authenticated task. - """ - - authentication_classes = (TaskAuthentication, ) - permission_classes = (IsTask, ) - serializer_class = NewTaskSerializer - - @extend_schema(tags=["ponos"]) @extend_schema_view( put=extend_schema( @@ -417,34 +169,3 @@ class TaskUpdate(UpdateAPIView): permission_classes = (IsTaskAdmin, ) queryset = Task.objects.select_related("process__corpus", "process__revision__repo") serializer_class = TaskTinySerializer - - -@extend_schema(tags=["ponos"]) -class SecretDetails(RetrieveAPIView): - """ - Retrieve a Ponos secret's content as cleartext. - - Requires authentication as a Ponos task. - """ - - authentication_classes = (TaskAuthentication, ) - permission_classes = (IsTask, ) - serializer_class = ClearTextSecretSerializer - queryset = Secret.objects.all() - lookup_field = "name" - - -@extend_schema(tags=["ponos"]) -class FarmList(ListAPIView): - """ - List all available farms. - - Cannot be used with Ponos agent or task authentication. - """ - serializer_class = FarmSerializer - queryset = Farm.objects.none() - authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (IsVerified, ) - - def get_queryset(self): - return Farm.objects.available(self.request.user).order_by("name") diff --git a/arkindex/ponos/authentication.py b/arkindex/ponos/authentication.py index 7fc029bfe9..459b42fd92 100644 --- a/arkindex/ponos/authentication.py +++ b/arkindex/ponos/authentication.py @@ -1,77 +1,11 @@ from drf_spectacular.authentication import TokenScheme -from drf_spectacular.contrib.rest_framework_simplejwt import SimpleJWTScheme from rest_framework.authentication import TokenAuthentication from rest_framework.exceptions import AuthenticationFailed -from arkindex.ponos.models import Agent, Task -from rest_framework_simplejwt.authentication import JWTAuthentication -from rest_framework_simplejwt.exceptions import InvalidToken -from rest_framework_simplejwt.settings import api_settings +from arkindex.ponos.models import Task from sentry_sdk import set_tag -class AgentUser(Agent): - """ - A proxy model to implement the Django User interface on a Ponos agent. - Allows Django REST Framework's usual permissions (like IsAuthenticated) to work. - """ - - is_staff: bool = False - is_superuser: bool = False - is_authenticated: bool = True - is_anonymous: bool = False - is_admin: bool = False - is_active: bool = True - is_agent: bool = True - verified_email: bool = False - - @property - def username(self) -> str: - return str(self.id) - - def get_username(self) -> str: - return self.username - - class Meta: - proxy = True - - def save(self, *args, update_fields=None, **kwargs): - """ - django.contrib.auth adds a 'user_logged_in' signal and a 'update_last_login' receiver, - which tries to update the User.last_login field. - The receiver is only added when the User model has a last_login field, which depends on - the Django app integrating Ponos and not on Ponos itself, so we have to handle it. - The receiver calls .save(update_fields=['last_login']), so we remove it. - An empty `update_fields` will cause a save to abort silently. - """ - if update_fields is not None and "last_login" in update_fields: - update_fields = set(update_fields) - {"last_login"} - return super().save(*args, update_fields=update_fields, **kwargs) - - -class AgentAuthentication(JWTAuthentication): - """ - Allows authenticating as a Ponos agent using a JSON Web Token. - """ - - def get_user(self, validated_token): - if api_settings.USER_ID_CLAIM not in validated_token: - raise InvalidToken("Token does not hold agent information") - try: - return AgentUser.objects.get(id=validated_token[api_settings.USER_ID_CLAIM]) - except AgentUser.DoesNotExist: - raise AuthenticationFailed("Agent not found") - - -class AgentAuthenticationExtension(SimpleJWTScheme): - """ - drf_spectacular extension to make it recognize the AgentAuthentication. - """ - - target_class = "arkindex.ponos.authentication.AgentAuthentication" - name = "agentAuth" - - class TaskAuthentication(TokenAuthentication): keyword = "Ponos" model = Task diff --git a/arkindex/ponos/keys.py b/arkindex/ponos/keys.py deleted file mode 100644 index 137da04232..0000000000 --- a/arkindex/ponos/keys.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -import os.path -from os import urandom - -from cryptography.exceptions import InvalidKey -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.hashes import SHA256 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, load_pem_private_key -from django.conf import settings - -logger = logging.getLogger(__name__) - - -def gen_nonce(size=16): - """ - Generates a simple nonce - Number size si defined in bytes (defaults to 128 bits) - https://cryptography.io/en/latest/glossary/#term-nonce - """ - return urandom(size) - - -def gen_private_key(dest_path) -> None: - """ - Generates an elliptic curve private key and saves it to a local file in PEM format. - See https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/ - - :param dest_path: Path to save the new key to. - :type dest_path: str or path-like object - """ - key = ec.generate_private_key( - ec.SECP384R1(), - default_backend(), - ) - - with open(dest_path, "wb") as f: - f.write( - key.private_bytes( - Encoding.PEM, - PrivateFormat.PKCS8, - NoEncryption(), - ) - ) - - -def load_private_key(): - """ - Load an existing private key from the path given in the ``PONOS_PRIVATE_KEY`` setting. - - :returns: An elliptic curve private key instance. - :raises Exception: When the Django ``DEBUG`` setting is set to False - and the server is misconfigured or the key is not found or invalid - """ - - def _abort(message): - """ - On Debug, be nice with developers, just display a warning - On Prod, simply crash - """ - if getattr(settings, "DEBUG", False): - logger.warning("Please fix your security configuration: {}".format(message)) - else: - raise Exception(message) - - if not getattr(settings, "PONOS_PRIVATE_KEY", None): - return _abort("Missing setting PONOS_PRIVATE_KEY") - - if not os.path.exists(settings.PONOS_PRIVATE_KEY): - return _abort( - "Invalid PONOS_PRIVATE_KEY path: {}".format(settings.PONOS_PRIVATE_KEY) - ) - - with open(settings.PONOS_PRIVATE_KEY, "rb") as f: - key = load_pem_private_key( - f.read(), - password=None, - backend=default_backend(), - ) - assert isinstance( - key, ec.EllipticCurvePrivateKey - ), "Private {} key is not an ECDH key".format(settings.PONOS_PRIVATE_KEY) - return key - - -def check_agent_key(agent_public_key, agent_derivation, seed) -> bool: - """ - Authenticates a new agent using its public key and a derivation - of its private key with the server's public key. - - :param agent_public_key: An agent's public key. - :type agent_public_key: cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey - :param agent_derivation bytes: A bytestring representing the server's public key - derived with the agent's private key using HKDF and holding a :class:`~`onos.models.Farm`'s seed. - :param seed str: The expected farm seed. - """ - shared_key = load_private_key().exchange(ec.ECDH(), agent_public_key) - - hkdf = HKDF( - algorithm=SHA256(), - backend=default_backend(), - length=32, - salt=None, - info=seed.encode("utf-8"), - ) - - try: - hkdf.verify(shared_key, agent_derivation) - return True - except InvalidKey: - return False diff --git a/arkindex/ponos/management/__init__.py b/arkindex/ponos/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/arkindex/ponos/management/commands/__init__.py b/arkindex/ponos/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/arkindex/ponos/management/commands/generate_private_key.py b/arkindex/ponos/management/commands/generate_private_key.py deleted file mode 100644 index 9f4161b66b..0000000000 --- a/arkindex/ponos/management/commands/generate_private_key.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.core.management.base import BaseCommand - -from arkindex.ponos.keys import gen_private_key - - -class Command(BaseCommand): - help = "Generate a Ponos server private key" - - def add_arguments(self, parser): - parser.add_argument( - "path", - help="Destination file for the key in PEM format.", - ) - - def handle(self, *args, path=None, **kwargs): - gen_private_key(path) - self.stdout.write( - self.style.SUCCESS("Generated a new private key to {}".format(path)) - ) diff --git a/arkindex/ponos/migrations/0001_initial.py b/arkindex/ponos/migrations/0001_initial.py index e7126d647a..7a72645956 100644 --- a/arkindex/ponos/migrations/0001_initial.py +++ b/arkindex/ponos/migrations/0001_initial.py @@ -9,7 +9,6 @@ import enumfields.fields from django.contrib.postgres.operations import HStoreExtension from django.db import migrations, models -import arkindex.ponos.keys import arkindex.ponos.models import arkindex.project.fields import arkindex.project.validators @@ -77,7 +76,7 @@ class Migration(migrations.Migration): fields=[ ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ("name", models.CharField(max_length=250, unique=True)), - ("nonce", models.BinaryField(default=arkindex.ponos.keys.gen_nonce, max_length=16)), + ("nonce", models.BinaryField(default=arkindex.ponos.models.gen_nonce, max_length=16)), ("content", models.BinaryField(editable=True)), ], ), @@ -136,17 +135,6 @@ class Migration(migrations.Migration): name="farm", field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="ponos.farm"), ), - migrations.CreateModel( - name="AgentUser", - fields=[ - ], - options={ - "proxy": True, - "indexes": [], - "constraints": [], - }, - bases=("ponos.agent",), - ), migrations.AddConstraint( model_name="workflow", constraint=models.CheckConstraint(check=models.Q(("finished", None), ("finished__gte", models.F("created")), _connector="OR"), name="ponos_workflow_finished_after_created"), diff --git a/arkindex/ponos/models.py b/arkindex/ponos/models.py index 85161882bc..ad991175c5 100644 --- a/arkindex/ponos/models.py +++ b/arkindex/ponos/models.py @@ -1,86 +1,37 @@ import base64 -import logging import os.path import random import uuid -from collections import namedtuple from datetime import timedelta -from hashlib import sha256 +from os import urandom from botocore.exceptions import ClientError -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import HStoreField -from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator, MinValueValidator, RegexValidator -from django.db import models, transaction -from django.db.models import Count, Exists, OuterRef, Q +from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils import timezone from enumfields import Enum, EnumField -from arkindex.ponos.keys import gen_nonce from arkindex.ponos.managers import FarmManager, TaskManager from arkindex.project.aws import S3FileMixin from arkindex.project.validators import MaxValueValidator -from arkindex.users.models import Role -from rest_framework_simplejwt.tokens import RefreshToken - -# Maximum allowed time until an agent is considered inactive since last request -AGENT_TIMEOUT = timedelta( - **getattr(settings, "PONOS_ACTIVE_AGENT_TIMEOUT", {"seconds": 30}) -) -# Estimation of required resources to run a task on an agent. Defaults to 1 core and 1GB of RAM -AGENT_SLOT = getattr(settings, "PONOS_TASK_SLOT", {"cpu": 1, "ram": 1e9}) - -Action = namedtuple("Action", "action, task") -Action.__doc__ = """ -Describes an instruction sent to an agent. -Can optionally be associated with a :class:`Task`. -""" - -Action.action.__doc__ = """ -Type of action to perform. - -:type: arkindex.ponos.models.ActionType -""" - -Action.task.__doc__ = """ -Optionally associated Task, when relevant. - -:type: arkindex.ponos.models.Task -""" - -logger = logging.getLogger(__name__) -class ActionType(Enum): - """ - Describes which action an agent should perform. - """ - - StartTask = "start_task" - """ - Instruct the agent to start a new task. - The action must have an associated task. - """ +def generate_seed() -> str: + return "{:064x}".format(random.getrandbits(256)) - StopTask = "stop_task" - """ - Instruct the agent to stop a running task. - The action must have an associated task. - """ - Kill = "kill" +def gen_nonce(size=16): """ - Instruct the agent to shut itself down. - This is the only case where the agent will have a 0 exit code. + Generates a simple nonce + Number size is defined in bytes (defaults to 128 bits) + https://cryptography.io/en/latest/glossary/#term-nonce """ - - -def generate_seed() -> str: - return "{:064x}".format(random.getrandbits(256)) + return urandom(size) class Farm(models.Model): @@ -112,19 +63,7 @@ class Farm(models.Model): return "Farm {}".format(self.name) def is_available(self, user) -> bool: - """ - Whether this farm is visible to the user and can be used to run processes. - """ - if user.is_anonymous or getattr(user, "is_agent", False): - return False - - if user.is_admin: - return True - - from arkindex.users.utils import get_max_level - level = get_max_level(user, self) - - return level is not None and level >= Role.Guest.value + return True class Agent(models.Model): @@ -149,148 +88,9 @@ class Agent(models.Model): ram_load = models.FloatField(null=True, blank=True) last_ping = models.DateTimeField(editable=False) - @property - def token(self) -> RefreshToken: - """ - JSON Web Token for this agent. - """ - return RefreshToken.for_user(self) - - @property - def active(self) -> bool: - return self.last_ping >= timezone.now() - AGENT_TIMEOUT - def __str__(self) -> str: return self.hostname - def delete(self) -> None: - if self.tasks.exclude(state__in=FINAL_STATES).exists(): - raise ValidationError( - "This agent has one or more tasks in non-final states." - ) - return super().delete() - - def _estimate_new_tasks_cost(self, tasks=1): - """ - Metric used to estimate the load on this agent starting new tasks. - Used as the cost function to minimize overall agents load while attributing tasks. - An agent cannot have more tasks that their CPU count, as the system cannot really process tasks faster. - - :param tasks: Number of tasks to estimate the cost for. - :returns: A cost expressed as a percentage. If > 1, the agent would be overloaded. - """ - if self.cpu_load is None or self.ram_load is None: - # The agent has not shared its state yet - return 1 - current_tasks_count = getattr(self, "current_tasks", 0) - if current_tasks_count + AGENT_SLOT["cpu"] >= self.cpu_cores: - return 1 - - cpu_cost = (self.cpu_load + tasks * AGENT_SLOT["cpu"]) / self.cpu_cores - ram_cost = self.ram_load + tasks * AGENT_SLOT["ram"] / self.ram_total - return max(cpu_cost, ram_cost) - - def next_tasks(self): - """ - Compute the next tasks that should be run by an agent. - Pending tasks are attributed to agents with the lightest load first. - - :returns: A list of tasks. - """ - if self._estimate_new_tasks_cost() >= 1: - # The capacity of this agent does not allow a new task to be started - return [] - - # Filter pending task on the agent farm ordered by higher priority first and then seniority (older first) - pending_tasks = Task.objects.filter( - Q(process__farm_id=self.farm_id) - & Q( - Q(state=State.Pending, agent=None) | Q(state=State.Unscheduled, depth=0) - ) - ).order_by("-priority", "updated") - - if not pending_tasks.exists(): - return [] - - # Retrieve active agents within the same farm, using the default database to avoid a stale-read if there are - # no active agents, then an agent becomes active and asks for its actions - active_agents = ( - Agent.objects.using("default") - .filter( - last_ping__gte=timezone.now() - AGENT_TIMEOUT, - farm_id=self.farm_id, - ) - .annotate( - current_tasks=Count("tasks", filter=Q(tasks__state=State.Running)) - ) - ) - - # List available GPUs (without any active task assigned) - available_gpus = list( - self.gpus - .using("default") - .filter(~Exists( - Task.objects.filter(gpu=OuterRef("pk"), state__in=ACTIVE_STATES) - )) - .only("id", "agent_id") - ) - - # Simulate the attribution of pending tasks minimizing overall load - attributed_tasks = {agent: [] for agent in active_agents} - for task in pending_tasks: - - # High priority for tasks with GPU requirements - if task.requires_gpu: - if available_gpus: - task.gpu = available_gpus.pop() - min_cost_agent = self - else: - # Skip tasks requiring GPU when none is available - logger.info(f"No GPU available for task {task.id} - {task}") - continue - else: - # Compare the cost of adding a new task on compatible agents - min_cost_agent = min( - active_agents, - key=lambda agent: agent._estimate_new_tasks_cost(tasks=len(attributed_tasks[agent]) + 1), - ) - - # Append the task to the queue of the agent with the minimal cost - current_tasks = attributed_tasks[min_cost_agent] - if min_cost_agent._estimate_new_tasks_cost(tasks=len(current_tasks) + 1) >= 1: - # Attributing the next task would overload the system - break - current_tasks.append(task) - - # Return tasks attributed to the agent making the request - return attributed_tasks[self] - - @transaction.atomic - def next_actions(self): - """ - Compute the next actions to send to an agent. - - This method must run in a single transaction to avoid fetching tasks that are being - assigned to another agent. - - :returns: List of :obj:`Action`. - """ - pending_tasks = self.tasks.filter(state=State.Pending) - next_tasks = self.next_tasks() - stop_tasks = self.tasks.filter(state=State.Stopping) - - actions = [] - for task in stop_tasks: - actions.append(Action(ActionType.StopTask, task)) - for task in pending_tasks: - actions.append(Action(ActionType.StartTask, task)) - for task in next_tasks: - task.agent = self - task.state = State.Pending - task.save() - actions.append(Action(ActionType.StartTask, task)) - return actions - class State(Enum): """ @@ -625,29 +425,6 @@ class Artifact(S3FileMixin, models.Model): ) -def build_aes_cipher(nonce): - """ - Initialize an AES cipher using the Ponos private key - """ - key_path = getattr(settings, "PONOS_PRIVATE_KEY", None) - assert key_path and os.path.exists(key_path), "Missing a PONOS_PRIVATE_KEY" - # Use the Ponos private key SHA-256 as the AES key - with open(settings.PONOS_PRIVATE_KEY, "rb") as f: - ponos_key = f.read() - aes_key = sha256(ponos_key).digest() - return Cipher(algorithms.AES(aes_key), modes.CTR(nonce)) - - -def encrypt(nonce, plain_text): - """ - Encrypt a plain text using the AES cipher model content ciphering a text from the Ponos private key - """ - cipher = build_aes_cipher(nonce) - encryptor = cipher.encryptor() - plain_text = plain_text.encode() - return encryptor.update(plain_text) - - class Secret(models.Model): """ A secret encrypted with a derivate of the Ponos server private key (ECDH) @@ -670,14 +447,6 @@ class Secret(models.Model): def __str__(self): return self.name - def decrypt(self): - """ - Returns the plain text deciphered from the Ponos private key - """ - decryptor = build_aes_cipher(self.nonce).decryptor() - plain_text = decryptor.update(self.content) - return plain_text.decode() - class GPU(models.Model): """ diff --git a/arkindex/ponos/renderers.py b/arkindex/ponos/renderers.py deleted file mode 100644 index 9fe7006191..0000000000 --- a/arkindex/ponos/renderers.py +++ /dev/null @@ -1,19 +0,0 @@ -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat -from rest_framework.renderers import BaseRenderer - - -class PublicKeyPEMRenderer(BaseRenderer): - """ - A Django REST Framework renderer to serialize public keys as PEM. - """ - - media_type = "application/x-pem-file" - format = "pem" - - def render(self, data: ec.EllipticCurvePublicKey, *args, **kwargs) -> bytes: - assert isinstance(data, ec.EllipticCurvePublicKey) - return data.public_bytes( - encoding=Encoding.PEM, - format=PublicFormat.SubjectPublicKeyInfo, - ) diff --git a/arkindex/ponos/serializers.py b/arkindex/ponos/serializers.py index a040972365..0b28c78011 100644 --- a/arkindex/ponos/serializers.py +++ b/arkindex/ponos/serializers.py @@ -1,95 +1,24 @@ -import hashlib import logging -import uuid from textwrap import dedent from django.conf import settings from django.db import transaction from django.db.models import Exists, OuterRef -from django.shortcuts import reverse from django.utils import timezone +from django.utils.module_loading import import_string from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError -from arkindex.ponos.keys import check_agent_key -from arkindex.ponos.models import ( - FINAL_STATES, - GPU, - ActionType, - Agent, - Artifact, - Farm, - Secret, - State, - Task, - task_token_default, -) -from arkindex.ponos.serializer_fields import Base64Field, CurrentProcessDefault, PublicKeyField +from arkindex.ponos.models import FINAL_STATES, Artifact, State, Task from arkindex.ponos.signals import task_failure from arkindex.project.serializer_fields import EnumField from arkindex.project.triggers import notify_process_completion logger = logging.getLogger(__name__) - -class FarmSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.Farm` instance. - """ - - class Meta: - model = Farm - fields = ( - "id", - "name", - ) - - -class GPUSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.GPU` instance for public access. - """ - - class Meta: - model = GPU - fields = ( - "id", - "index", - "name", - "ram_total", - ) - read_only_fields = ( - "id", - "index", - "name", - "ram_total", - ) - - -class AgentLightSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.Agent` instance for public access. - """ - - farm = FarmSerializer() - gpus = GPUSerializer(many=True, read_only=True) - - class Meta: - model = Agent - fields = ( - "id", - "farm", - "hostname", - "cpu_cores", - "cpu_frequency", - "ram_total", - "cpu_load", - "ram_load", - "last_ping", - "gpus", - ) - read_only_fields = ("id", "last_ping") +TaskAgentField = import_string(getattr(settings, "TASK_AGENT_FIELD", None) or "arkindex.project.serializer_fields.NullField") +TaskGPUField = import_string(getattr(settings, "TASK_GPU_FIELD", None) or "arkindex.project.serializer_fields.NullField") class TaskLightSerializer(serializers.ModelSerializer): @@ -149,8 +78,8 @@ class TaskSerializer(TaskLightSerializer): logs = serializers.SerializerMethodField() full_log = serializers.SerializerMethodField() - agent = AgentLightSerializer(read_only=True, allow_null=True) - gpu = GPUSerializer(read_only=True, allow_null=True) + agent = TaskAgentField(read_only=True, allow_null=True) + gpu = TaskGPUField(read_only=True, allow_null=True) extra_files = serializers.DictField(read_only=True) class Meta(TaskLightSerializer.Meta): @@ -286,224 +215,6 @@ class TaskTinySerializer(TaskSerializer): return super().update(instance, validated_data) -class AgentStateSerializer(AgentLightSerializer): - """ - Serialize an :class:`~arkindex.ponos.models.Agent` with its state information - And the GPU state - """ - - running_tasks_count = serializers.IntegerField(min_value=0) - - class Meta(AgentLightSerializer.Meta): - fields = AgentLightSerializer.Meta.fields + ("active", "running_tasks_count") - - -class AgentDetailsSerializer(AgentLightSerializer): - """ - Serialize an :class:`~arkindex.ponos.models.Agent` with its running tasks - """ - - running_tasks = serializers.SerializerMethodField() - - class Meta(AgentLightSerializer.Meta): - fields = AgentLightSerializer.Meta.fields + ("active", "running_tasks") - - @extend_schema_field(TaskLightSerializer(many=True)) - def get_running_tasks(self, agent): - running_tasks = agent.tasks.filter(state=State.Running) - task_serializer = TaskLightSerializer( - instance=running_tasks, many=True, context=self.context - ) - return task_serializer.data - - -class GPUCreateSerializer(serializers.Serializer): - """ - Serialize a :class:`~arkindex.ponos.models.GPU` attached to an Agent - """ - - id = serializers.UUIDField() - name = serializers.CharField() - index = serializers.IntegerField() - ram_total = serializers.IntegerField() - - -class AgentCreateSerializer(serializers.ModelSerializer): - """ - Serializer used to register a new :class:`~arkindex.ponos.models.Agent`. - """ - - public_key = PublicKeyField(write_only=True) - derivation = Base64Field(write_only=True) - access_token = serializers.CharField(read_only=True, source="token.access_token") - refresh_token = serializers.CharField(read_only=True, source="token") - gpus = GPUCreateSerializer(many=True) - - class Meta: - model = Agent - fields = ( - "id", - "farm", - "access_token", - "refresh_token", - "public_key", - "derivation", - "hostname", - "cpu_cores", - "cpu_frequency", - "ram_total", - "cpu_load", - "ram_load", - "last_ping", - "gpus", - ) - read_only_fields = ( - "id", - "access_token", - "refresh_token", - "last_ping", - ) - - def create(self, validated_data): - """Create the required agent and its GPUs""" - gpus = validated_data.pop("gpus", None) - - with transaction.atomic(using="default"): - - # Create Agent as usual - agent = super().create(validated_data) - - # Create or update GPUs - # When an agent's private key is regenerated on the same host, an existing GPU UUID might be sent, - # so we need to handle existing GPUs to avoid errors. - for gpu_data in gpus: - GPU.objects.update_or_create( - id=gpu_data.pop("id"), defaults=dict(agent=agent, **gpu_data) - ) - - return agent - - def update(self, instance, validated_data): - """Create the required agent and its GPUs""" - gpus = validated_data.pop("gpus", None) - - with transaction.atomic(using="default"): - - # Update Agent as usual - agent = super().update(instance, validated_data) - - # Delete existing GPUs - agent.gpus.all().delete() - - # Create Gpus - the ID should not evolve as it's provided - # by the host - for gpu_data in gpus: - agent.gpus.create(**gpu_data) - - return agent - - def validate(self, data): - if not check_agent_key( - data["public_key"], data["derivation"], data["farm"].seed - ): - raise serializers.ValidationError("Key verification failed") - del data["derivation"] - - # Turn the public key back into a string to be saved in the agent - # TODO: Use a custom Django model field for this? - data["public_key"] = PublicKeyField().to_representation(data["public_key"]) - - # Generate the agent ID as a MD5 hash of its public key - data["id"] = uuid.UUID( - hashlib.md5(data["public_key"].encode("utf-8")).hexdigest() - ) - - # Set last ping as agent is about to be registered - data["last_ping"] = timezone.now() - - return data - - -class ActionSerializer(serializers.Serializer): - """ - Serializes an :const:`~arkindex.ponos.models.Action` instance. - """ - - action = EnumField(ActionType) - task_id = serializers.SerializerMethodField() - - @extend_schema_field(serializers.UUIDField(allow_null=True)) - def get_task_id(self, obj): - if not obj.task: - return - return str(obj.task.id) - - -class AgentActionsSerializer(serializers.Serializer): - """ - Serializes multiple next actions for an agent. - """ - - actions = ActionSerializer(many=True, source="next_actions") - - -class TaskDefinitionSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.Task` instance - to provide startup information to assigned agents. - """ - - env = serializers.DictField(default={}) - process_id = serializers.UUIDField() - agent_id = serializers.PrimaryKeyRelatedField(queryset=Agent.objects.all()) - image_artifact_url = serializers.SerializerMethodField() - s3_logs_put_url = serializers.SerializerMethodField() - extra_files = serializers.DictField(default={}) - state = EnumField(State) - - @extend_schema_field(serializers.URLField(allow_null=True)) - def get_image_artifact_url(self, task): - """Build url on the API to get a fresh download link""" - if not task.image_artifact: - return - return self.context["request"].build_absolute_uri( - reverse( - "api:task-artifact-download", - kwargs={ - "pk": task.image_artifact.task_id, - "path": task.image_artifact.path, - }, - ) - ) - - @extend_schema_field(serializers.URLField(allow_null=True)) - def get_s3_logs_put_url(self, obj): - if "request" not in self.context or self.context["request"].user != obj.agent: - return - return obj.logs.s3_put_url - - class Meta: - model = Task - fields = ( - "id", - "slug", - "image", - "command", - "env", - "shm_size", - "has_docker_socket", - "image_artifact_url", - "agent_id", - "s3_logs_put_url", - "parents", - "process_id", - "gpu_id", - "extra_files", - "state", - ) - read_only_fields = fields - - class ArtifactSerializer(serializers.ModelSerializer): """ Serializes a :class:`~arkindex.ponos.models.Artifact` instance to allow @@ -545,94 +256,3 @@ class ArtifactSerializer(serializers.ModelSerializer): raise ValidationError("An artifact with this path already exists") return path - - -class NewTaskSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.Task` instance to permit creation by a parent. - """ - - process = serializers.HiddenField(default=CurrentProcessDefault()) - process_id = serializers.UUIDField(read_only=True) - command = serializers.CharField(required=False) - env = serializers.DictField( - child=serializers.CharField(), required=False, default={} - ) - has_docker_socket = serializers.BooleanField(default=False) - - class Meta: - model = Task - fields = ( - "id", - "process", - "process_id", - "slug", - "parents", - "image", - "command", - "env", - "run", - "depth", - "has_docker_socket", - ) - read_only_fields = ( - "id", - "run", - "depth", - "process_id", - ) - - def validate(self, data): - parents = data["parents"] - - ids = {parent.process_id for parent in parents} - if len(ids) != 1 or str(ids.pop()) != str(data["process"].id): - raise ValidationError( - "All parents must be in the same process as the child task" - ) - - runs = {parent.run for parent in parents} - if len(runs) != 1: - raise ValidationError( - "All parents must have the same run in the given process" - ) - data["run"] = runs.pop() - - if Task.objects.filter( - process=data["process"], run=data["run"], slug=data["slug"] - ).exists(): - raise ValidationError( - f"A task with the `{data['slug']}` slug already exists in run {data['run']}." - ) - - data["depth"] = max(parent.depth for parent in parents) + 1 - - # Build task environment from PONOS_DEFAULT_ENV if the env is not (fully) defined in data - data["env"] = {**settings.PONOS_DEFAULT_ENV, **data["env"]} - data["env"]["ARKINDEX_PROCESS_ID"] = str(data["process"].id) - # Get corpus id from any of the parent tasks since it's the same for all of them - # TODO: Get the corpus ID from the process using a ForeignKeyField on the serializer and data["process"] - data["env"]["ARKINDEX_CORPUS_ID"] = parents[0].env.get("ARKINDEX_CORPUS_ID", None) - - # Set the task token manually so that we can immediately copy it to the environment variables, - # just like what is done in Workflow.build_tasks() - data["token"] = task_token_default() - data["env"]["ARKINDEX_TASK_TOKEN"] = data["token"] - - return super().validate(data) - - -class ClearTextSecretSerializer(serializers.ModelSerializer): - """ - Serializes a :class:`~arkindex.ponos.models.Secret` instance with its content in cleartext. - """ - - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, secret) -> str: - return secret.decrypt() - - class Meta: - model = Secret - fields = ("id", "name", "content") - read_only_fields = ("id", "content") diff --git a/arkindex/ponos/tasks.py b/arkindex/ponos/tasks.py index 0b6aca3b5a..4c53bb7be2 100644 --- a/arkindex/ponos/tasks.py +++ b/arkindex/ponos/tasks.py @@ -1,4 +1,8 @@ import logging +import tempfile +from io import BytesIO +from pathlib import Path +from time import sleep from urllib.parse import urljoin from django.conf import settings @@ -9,10 +13,17 @@ from django.shortcuts import reverse from django.template.loader import render_to_string from django_rq import job +import docker +from arkindex.ponos.models import State, Task +from arkindex.ponos.utils import upload_artifact from arkindex.process.models import Process, WorkerActivityState +from docker.errors import APIError, ImageNotFound logger = logging.getLogger(__name__) +# Delay for polling docker task's logs in seconds +TASK_DOCKER_POLLING = 1 + @job("default", timeout=settings.RQ_TIMEOUTS["notify_process_completion"]) def notify_process_completion( @@ -64,3 +75,153 @@ def notify_process_completion( recipient_list=[process.creator.email], fail_silently=False, ) + + +def upload_logs(task, text): + try: + task.logs.s3_object.upload_fileobj( + BytesIO(text), + ExtraArgs={"ContentType": "text/plain; charset=utf-8"}, + ) + except Exception as e: + logger.warning(f"Failed uploading logs for task {task}: {e}") + + +def run_docker_task(client, task, temp_dir): + # 1. Pull the docker image + logger.debug(f"Pulling docker image '{task.image}'") + try: + client.images.pull(task.image) + except (ImageNotFound, APIError) as e: + # Pulling is allowed to fail when the image is already present locally (local builds) + if not client.images.list(task.image): + raise Exception(f"Image not found locally nor remotely: {e}") + logger.info("Remote image could not be fetched, using the local image.") + + # 2. Fetch artifacts + logger.info("Fetching artifacts from parents") + for parent in task.parents.order_by("depth", "id"): + folder = temp_dir / str(parent.slug) + folder.mkdir() + for artifact in parent.artifacts.all(): + path = (folder / artifact.path).resolve() + # Ensure path is a children of folder + assert str(folder.resolve()) in str(path.resolve()), "Invalid artifact path: {artifact.path}." + artifact.download_to(str(path)) + + # 3. Do run the container asynchronously + logger.debug("Running container") + kwargs = { + "environment": { + **task.env, + "PONOS_DATA": settings.PONOS_DATA_DIR, + }, + "detach": True, + "network": "host", + "volumes": {temp_dir: {"bind": str(settings.PONOS_DATA_DIR), "mode": "rw"}}, + } + artifacts_dir = temp_dir / str(task.id) + artifacts_dir.mkdir() + # The symlink will only work within docker context as bound to PONOS_DATA_DIR/<task_uuid>/ + (temp_dir / "current").symlink_to(Path(settings.PONOS_DATA_DIR) / str(task.id)) + + if task.requires_gpu: + # Assign all GPUs to that container + # https://github.com/docker/docker-py/issues/2395#issuecomment-907243275 + kwargs["device_requests"] = [ + docker.types.DeviceRequest(count=-1, capabilities=[["gpu"]]) + ] + logger.info("Starting container with GPU support") + if task.command is not None: + kwargs["command"] = task.command + container = client.containers.run(task.image, **kwargs) + while container.status == "created": + container.reload() + task.state = State.Running + task.save() + + # 4. Read logs + logger.debug("Reading logs from the docker container") + previous_logs = b"" + while container.status == "running": + logs = container.logs() + if logs != previous_logs: + upload_logs(task, logs) + previous_logs = logs + # Handle a task that is being stopped during execution + task.refresh_from_db() + if task.state == State.Stopping: + container.stop() + task.state = State.Stopped + task.save() + break + sleep(TASK_DOCKER_POLLING) + container.reload() + # Upload logs one last time so we do not miss any data + upload_logs(task, container.logs()) + + # 5. Retrieve the state of the container + container.reload() + exit_code = container.attrs["State"]["ExitCode"] + if exit_code != 0: + logger.info("Task failed") + task.state = State.Failed + task.save() + return + + task.state = State.Completed + task.save() + + # 6. Upload artifacts + logger.info(f"Uploading artifacts for task {task}") + + for path in Path(artifacts_dir).glob("**/*"): + if path.is_dir(): + continue + try: + upload_artifact(task, path, artifacts_dir) + except Exception as e: + logger.warning( + f"Failed uploading artifact {path} for task {task}: {e}" + ) + + +@job("tasks", timeout=settings.RQ_TIMEOUTS["task"]) +def run_task_rq(task: Task): + """Run a single task in RQ""" + # Update task and parents from the DB + task.refresh_from_db() + parents = list(task.parents.order_by("depth", "id")) + + client = docker.from_env() + + if not task.image: + raise ValueError(f"Task {task} has no docker image.") + + if task.state != State.Pending: + raise ValueError(f"Task {task} must be in pending state to run in RQ.") + + # Automatically update children in case an error occurred + if (parent_state := next( + (parent.state for parent in parents if parent.state in (State.Stopped, State.Error, State.Failed)), + None + )) is not None: + task.state = parent_state + task.save() + return + + with tempfile.TemporaryDirectory(suffix=f"_{task.id}") as temp_dir: + try: + run_docker_task(client, task, Path(temp_dir)) + except Exception as e: + logger.error(f"An unexpected error occurred, updating state to Error: {e}") + task.state = State.Error + task.save() + # Add unexpected error details to task logs + text = BytesIO() + if task.logs.exists(): + task.logs.s3_object.download_fileobj(text) + text = text.getvalue() + text += f"\nPonos exception: {e}".encode() + upload_logs(task, text) + raise e diff --git a/arkindex/ponos/tests/test_admin.py b/arkindex/ponos/tests/test_admin.py deleted file mode 100644 index 06d68609a7..0000000000 --- a/arkindex/ponos/tests/test_admin.py +++ /dev/null @@ -1,47 +0,0 @@ -from pathlib import Path - -from django.test import TestCase, override_settings - -from arkindex.ponos.admin import ClearTextSecretForm, SecretAdmin -from arkindex.ponos.models import Secret, encrypt - -PONOS_PRIVATE_KEY = Path(__file__).absolute().parent / "fixtures" / "ponos.key" - - -class TestAdmin(TestCase): - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_admin_read_secret(self): - """ - Admin form display the decrypted content of the stored secrets - """ - nonce = b"1337" * 4 - encrypted = encrypt(nonce, "Shhhh") - secret = Secret.objects.create( - name="important_secret", - nonce=b"1337" * 4, - content=encrypted, - ) - self.assertEqual(secret.content, b"\xa3\xda\x9b\x91#") - form = ClearTextSecretForm(instance=secret) - self.assertEqual(form.initial.get("content"), "Shhhh") - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_admin_updates_secret(self): - nonce = b"1337" * 4 - encrypted = encrypt(nonce, "Shhhh") - secret = Secret.objects.create( - name="important_secret", - nonce=b"1337" * 4, - content=encrypted, - ) - - secret_admin = SecretAdmin(model=Secret, admin_site=None) - form = ClearTextSecretForm( - data={"id": secret.id, "name": secret.name, "content": "Ah"}, - instance=secret, - ) - self.assertEqual(secret.content, b"\xa3\xda\x9b\x91#") - secret_admin.save_form(request=None, form=form, change=True) - self.assertEqual(secret.content, b"\xb1\xda") - self.assertEqual(b"\xb1\xda", encrypt(secret.nonce, "Ah")) diff --git a/arkindex/ponos/tests/test_api.py b/arkindex/ponos/tests/test_api.py index 7456f22a26..713836fb26 100644 --- a/arkindex/ponos/tests/test_api.py +++ b/arkindex/ponos/tests/test_api.py @@ -1,42 +1,17 @@ -import base64 -import hashlib -import random -import uuid from io import BytesIO -from pathlib import Path -from textwrap import dedent -from unittest.mock import PropertyMock, call, patch, seal +from unittest import expectedFailure +from unittest.mock import call, patch, seal from botocore.exceptions import ClientError -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.hashes import SHA256 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key -from django.conf import settings -from django.core import mail from django.test import override_settings from django.urls import reverse -from django.utils import timezone from rest_framework import status from arkindex.documents.models import Corpus -from arkindex.ponos.api import timezone as api_tz -from arkindex.ponos.authentication import AgentUser -from arkindex.ponos.models import ACTIVE_STATES, FINAL_STATES, GPU, Agent, Farm, Secret, State, Task, encrypt -from arkindex.ponos.tasks import notify_process_completion -from arkindex.process.models import Process, ProcessMode, Revision, WorkerActivityState, WorkerVersion +from arkindex.ponos.models import FINAL_STATES, State +from arkindex.process.models import Process, ProcessMode, Revision, WorkerVersion from arkindex.project.tests import FixtureAPITestCase -from arkindex.project.tools import build_public_key from arkindex.users.models import Right, Role, User -from rest_framework_simplejwt.tokens import AccessToken, RefreshToken - -PONOS_PRIVATE_KEY = Path(__file__).absolute().parent / "fixtures" / "ponos.key" - - -# Helper to format a datetime as output by DRF -def str_date(d): - return d.isoformat().replace("+00:00", "Z") @override_settings(PONOS_LOG_TAIL=42) @@ -47,21 +22,6 @@ class TestAPI(FixtureAPITestCase): super().setUpTestData() cls.page_1 = cls.corpus.elements.get(name="Volume 1, page 1r") cls.page_2 = cls.corpus.elements.get(name="Volume 1, page 2r") - cls.default_farm = Farm.objects.create(name="Default farm") - cls.wheat_farm = Farm.objects.get(name="Wheat farm") - pubkey = build_public_key() - cls.agent = AgentUser.objects.create( - id=uuid.UUID(hashlib.md5(pubkey.encode("utf-8")).hexdigest()), - farm=cls.wheat_farm, - hostname="ghostname", - cpu_cores=2, - cpu_frequency=1e9, - public_key=pubkey, - ram_total=2e9, - last_ping=timezone.now(), - cpu_load=.1, - ram_load=.1e9, - ) cls.rev = Revision.objects.first() cls.process = Process.objects.get(mode=ProcessMode.Workers) cls.process.run() @@ -75,30 +35,17 @@ class TestAPI(FixtureAPITestCase): mode=ProcessMode.Workers, creator=new_user, corpus=new_corpus, - farm=cls.default_farm, ) cls.process2.run() cls.task4 = cls.process2.tasks.first() cls.dla = WorkerVersion.objects.get(worker__slug="dla") cls.recognizer = WorkerVersion.objects.get(worker__slug="reco") - cls.gpu1 = cls.agent.gpus.create( - id="108c6524-c63a-4811-bbed-9723d32a0688", - name="GPU1", - index=0, - ram_total=2 * 1024 * 1024 * 1024, - ) - cls.gpu2 = cls.agent.gpus.create( - id="f30d1407-92bb-484b-84b0-0b8bae41ca91", - name="GPU2", - index=1, - ram_total=8 * 1024 * 1024 * 1024, - ) def test_task_details_requires_login(self): with self.assertNumQueries(0): resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) @patch("arkindex.project.aws.s3") def test_task_details_own_task(self, s3_mock): @@ -164,6 +111,7 @@ class TestAPI(FixtureAPITestCase): resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_task_details_requires_process_guest(self): self.process.creator = self.superuser self.process.save() @@ -195,7 +143,7 @@ class TestAPI(FixtureAPITestCase): with self.subTest(role=role): self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(6): + with self.assertNumQueries(4): resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) self.assertEqual(resp.status_code, status.HTTP_200_OK) @@ -249,7 +197,7 @@ class TestAPI(FixtureAPITestCase): membership.level = role.value membership.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) self.assertEqual(resp.status_code, status.HTTP_200_OK) @@ -321,2476 +269,387 @@ class TestAPI(FixtureAPITestCase): call("get_object", Params={"Bucket": "ponos", "Key": "somelog"}), ) - def test_task_definition(self): - with self.assertNumQueries(3): - response = self.client.get( - reverse("api:task-definition", args=[self.task1.id]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual(response.json(), { - "id": str(self.task1.id), - "slug": "initialisation", - "parents": [], - "image": settings.ARKINDEX_TASKS_IMAGE, - "command": f"python -m arkindex_tasks.init_elements {self.process.id} --chunks-number 1", - "shm_size": None, - "env": { - "ARKINDEX_API_CSRF_COOKIE": "arkindex.csrf", - "ARKINDEX_API_TOKEN": "deadbeefTestToken", - "ARKINDEX_API_URL": "http://localhost:8000/api/v1/", - "ARKINDEX_CORPUS_ID": str(self.corpus.id), - "ARKINDEX_PROCESS_ID": str(self.process.id), - "ARKINDEX_TASK_TOKEN": self.task1.token, - }, - "has_docker_socket": False, - "image_artifact_url": None, - "agent_id": None, - "gpu_id": None, - "process_id": str(self.process.id), - "extra_files": {}, - "s3_logs_put_url": None, - "state": State.Unscheduled.value, - }) - - def test_task_definition_requires_login(self): + def test_update_task_requires_login(self): with self.assertNumQueries(0): - response = self.client.get(reverse("api:task-definition", args=[self.task1.id])) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - def test_task_definition_requires_agent(self): + def test_update_task_requires_verified(self): + self.user.verified_email = False + self.user.save() self.client.force_login(self.user) - with self.assertNumQueries(0): - response = self.client.get(reverse("api:task-definition", args=[self.task1.id])) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - def test_task_definition_task_forbidden(self): + with self.assertNumQueries(2): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_task_forbids_task(self): with self.assertNumQueries(0): - response = self.client.get( - reverse("api:task-definition", args=[self.task1.id]), + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - def test_task_definition_image_artifact(self): - self.task1.image_artifact = self.task1.artifacts.create( - path="path/to/image.tar", - size=1234567, - content_type="application/x-tar", - ) - self.task1.save() + @expectedFailure + def test_update_task_requires_process_admin_corpus(self): + self.process.creator = self.superuser + self.process.save() + self.corpus.public = False + self.corpus.save() + self.client.force_login(self.user) - with self.assertNumQueries(4): - response = self.client.get( - reverse("api:task-definition", args=[self.task1.id]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + for role in [None, Role.Guest, Role.Contributor]: + with self.subTest(role=role): + self.corpus.memberships.filter(user=self.user).delete() + if role: + self.corpus.memberships.create(user=self.user, level=role.value) - self.assertDictEqual(response.json(), { - "id": str(self.task1.id), - "slug": "initialisation", - "parents": [], - "image": settings.ARKINDEX_TASKS_IMAGE, - "command": f"python -m arkindex_tasks.init_elements {self.process.id} --chunks-number 1", - "shm_size": None, - "env": { - "ARKINDEX_API_CSRF_COOKIE": "arkindex.csrf", - "ARKINDEX_API_TOKEN": "deadbeefTestToken", - "ARKINDEX_API_URL": "http://localhost:8000/api/v1/", - "ARKINDEX_CORPUS_ID": str(self.corpus.id), - "ARKINDEX_PROCESS_ID": str(self.process.id), - "ARKINDEX_TASK_TOKEN": self.task1.token, - }, - "has_docker_socket": False, - "image_artifact_url": f"http://testserver/api/v1/task/{str(self.task1.id)}/artifact/path/to/image.tar", - "agent_id": None, - "gpu_id": None, - "process_id": str(self.process.id), - "extra_files": {}, - "s3_logs_put_url": None, - "state": State.Unscheduled.value, - }) + with self.assertNumQueries(5): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + @expectedFailure + def test_update_task_requires_process_admin_repo(self): + self.process.mode = ProcessMode.Repository + self.process.corpus = None + self.process.revision = self.rev + self.process.creator = self.superuser + self.process.save() + self.client.force_login(self.user) + + for role in [None, Role.Guest, Role.Contributor]: + with self.subTest(role=role): + self.rev.repo.memberships.filter(user=self.user).delete() + if role: + self.rev.repo.memberships.create(user=self.user, level=role.value) + + with self.assertNumQueries(5): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - def test_task_definition_shm_size(self): - self.task1.shm_size = "128g" + def test_update_running_task_state_stopping(self): + self.task1.state = State.Running self.task1.save() + self.client.force_login(self.superuser) - with self.assertNumQueries(3): - response = self.client.get( - reverse("api:task-definition", args=[self.task1.id]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + with self.assertNumQueries(4): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { + self.assertDictEqual(resp.json(), { "id": str(self.task1.id), - "slug": "initialisation", - "parents": [], - "image": settings.ARKINDEX_TASKS_IMAGE, - "command": f"python -m arkindex_tasks.init_elements {self.process.id} --chunks-number 1", - "shm_size": "128g", - "env": { - "ARKINDEX_API_CSRF_COOKIE": "arkindex.csrf", - "ARKINDEX_API_TOKEN": "deadbeefTestToken", - "ARKINDEX_API_URL": "http://localhost:8000/api/v1/", - "ARKINDEX_CORPUS_ID": str(self.corpus.id), - "ARKINDEX_PROCESS_ID": str(self.process.id), - "ARKINDEX_TASK_TOKEN": self.task1.token, - }, - "has_docker_socket": False, - "image_artifact_url": None, - "agent_id": None, - "gpu_id": None, - "process_id": str(self.process.id), - "extra_files": {}, - "s3_logs_put_url": None, - "state": State.Unscheduled.value, + "state": State.Stopping.value, }) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, State.Stopping) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - def test_task_final_updates_process_finished( - self, timezone_mock, s3_mock - ): - """ - Updating a task to a final state should update the process's completion date - if it causes the whole process to be finished. - """ - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - - self.task1.agent = self.agent - self.task1.state = State.Completed - self.task1.save() - self.task2.agent = self.agent - self.task2.state = State.Completed - self.task2.save() + def test_update_non_running_task_state_stopping(self): + states = list(State) + states.remove(State.Running) + self.client.force_login(self.superuser) - for state in FINAL_STATES: + for state in states: with self.subTest(state=state): - if state == State.Stopped: - self.task3.state = State.Stopping - else: - self.task3.state = State.Running - self.task3.agent = self.agent - self.task3.save() - - self.process.finished = None - # No email is sent with mode Repository - self.process.mode = ProcessMode.Repository - self.process.corpus = None - self.process.save() - - with self.assertNumQueries(11): - resp = self.client.patch( - reverse("api:task-details", args=[self.task3.id]), - data={"state": state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + self.task1.state = state + self.task1.save() + + with self.assertNumQueries(3): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + resp.json(), + {"state": [f"Transition from state {state} to state Stopping is forbidden."]}, + ) - self.task3.refresh_from_db() - self.assertEqual(self.task3.state, state) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, state) - self.process.refresh_from_db() - self.assertEqual(self.process.finished, expected_datetime) + def test_update_final_task_state_pending(self): + self.task1.state = State.Completed + self.task1.save() + self.client.force_login(self.superuser) - # No email is sent for processes of type Repository - self.assertEqual(len(mail.outbox), 0) + with self.assertNumQueries(5): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Pending.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - def test_process_failed_finished(self, async_notify_mock, timezone_mock, s3_mock): - """ - When a task fails, its children stay unscheduled, and nothing will ever be running - in the process anymore. It should be marked as finished. - """ - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, State.Pending) + self.assertIsNone(self.task1.agent) + self.assertIsNone(self.task1.gpu) - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - seal(timezone_mock) + def test_update_non_final_task_state_pending(self): + states = set(State) - set(FINAL_STATES) + self.client.force_login(self.superuser) - self.task1.state = State.Completed - self.task3.state = State.Unscheduled - self.task1.save() - self.task3.save() - - for state in set(FINAL_STATES) - {State.Completed}: - self.task2.agent = self.agent - if state == State.Stopped: - self.task2.state = State.Stopping - else: - self.task2.state = State.Running - self.task2.save() + for state in states: with self.subTest(state=state): - self.process.finished = None - self.process.save() + self.task1.state = state + self.task1.save() - with self.assertNumQueries(11): - resp = self.client.patch( - reverse("api:task-details", args=[self.task2.id]), - data={"state": state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + with self.assertNumQueries(3): + resp = self.client.put( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Pending.value}, ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task2.refresh_from_db() - self.assertEqual(self.task2.state, state) - - self.process.refresh_from_db() - self.assertEqual(self.process.finished, expected_datetime) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - @override_settings(PUBLIC_HOSTNAME="http://ark.teklia/") - def test_process_finished_email_failure( - self, async_notify, timezone_mock, s3_mock - ): - async_notify.side_effect = notify_process_completion - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - seal(timezone_mock) - - self.process.elements.set((self.page_1, self.page_2)) - self.process.activities.create( - element=self.page_1, - worker_version=self.dla, - state=WorkerActivityState.Error.value, - ) - self.process.activities.create( - element=self.page_1, - worker_version=self.recognizer, - state=WorkerActivityState.Error.value, - ) + self.assertDictEqual( + resp.json(), + {"state": [f"Transition from state {state} to state Pending is forbidden."]} + ) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, state) - self.process.finished = None - self.process.name = None - self.process.save() - self.process.tasks.update(agent=self.agent, state=State.Completed) - self.process.tasks.filter(id=self.task3.id).update(state=State.Running) - with self.assertNumQueries(13): + def test_partial_update_task_from_agent_requires_login(self): + with self.assertNumQueries(0): resp = self.client.patch( - reverse("api:task-details", args=[self.task3.id]), - data={"state": State.Failed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + reverse("api:task-details", args=[self.task1.id]), + data={"state": State.Completed.value}, ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] - self.assertEqual(message.subject, f"Your process {self.process.id} finished with failures") - self.assertListEqual(message.to, [self.process.creator.email]) - self.assertEqual(message.body, dedent(f""" - Hello Test user, + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - A process you created just finished. - ID: {self.process.id} - Overall state: Failed + def test_partial_update_task_from_agent_forbids_users(self): + self.client.force_login(self.user) + with self.assertNumQueries(2): + resp = self.client.patch( + reverse("api:task-details", args=[self.task1.id]), + data={"state": State.Completed.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - Tasks summary: - * {self.task1.slug}: Completed - * {self.task2.slug}: Completed - * {self.task3.slug}: Failed + def test_partial_update_task_from_agent_forbids_task(self): + with self.assertNumQueries(1): + resp = self.client.patch( + reverse("api:task-details", args=[self.task1.id]), + data={"state": State.Completed.value}, + HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + def test_partial_update_task_requires_login(self): + with self.assertNumQueries(0): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - Your process has 2 workers with erroneous elements: - - Document layout analyser worker with 100% failures (1/1) + def test_partial_update_task_requires_verified(self): + self.user.verified_email = False + self.user.save() + self.client.force_login(self.user) - - Recognizer worker with 100% failures (1/1) + with self.assertNumQueries(2): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + def test_partial_update_task_forbids_task(self): + with self.assertNumQueries(0): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", + ) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - You can see all the details and logs here: http://ark.teklia/process/{self.process.id}/0 + @expectedFailure + def test_partial_update_task_requires_process_admin_corpus(self): + self.process.creator = self.superuser + self.process.save() + self.corpus.public = False + self.corpus.save() + self.client.force_login(self.user) - -- - Arkindex + for role in [None, Role.Guest, Role.Contributor]: + with self.subTest(role=role): + self.corpus.memberships.filter(user=self.user).delete() + if role: + self.corpus.memberships.create(user=self.user, level=role.value) - """)) + with self.assertNumQueries(5): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - @override_settings(PUBLIC_HOSTNAME="http://ark.teklia/") - def test_process_finished_email_stopped( - self, async_notify, timezone_mock, s3_mock - ): - async_notify.side_effect = notify_process_completion - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - seal(timezone_mock) - - # Add some worker activities - self.process.elements.set((self.page_1, self.page_2)) - self.process.activities.create( - element=self.page_1, - worker_version=self.dla, - state=WorkerActivityState.Error.value, - ) - self.process.activities.create( - element=self.page_2, - worker_version=self.dla, - state=WorkerActivityState.Processed.value, - ) - self.process.activities.create( - element=self.page_1, - worker_version=self.recognizer, - state=WorkerActivityState.Queued.value, - ) - - self.process.tasks.update(agent=self.agent, state=State.Completed) - self.process.tasks.filter(id=self.task3.id).update(state=State.Stopping) - with self.assertNumQueries(13): - resp = self.client.patch( - reverse("api:task-details", args=[self.task3.id]), - data={"state": State.Stopped.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] - self.assertEqual(message.subject, f"Your process {self.process.name} finished because it was stopped") - self.assertListEqual(message.to, [self.process.creator.email]) - self.assertEqual(message.body, dedent(f""" - Hello Test user, - - A process you created just finished. - ID: {self.process.id} - Overall state: Stopped - - Tasks summary: - * {self.task1.slug}: Completed - * {self.task2.slug}: Completed - * {self.task3.slug}: Stopped - - - Your process has 1 worker with erroneous elements: - - Document layout analyser worker with 50% failures (1/2) - - - You can see all the details and logs here: http://ark.teklia/process/{self.process.id}/0 + @expectedFailure + def test_partial_update_task_requires_process_admin_repo(self): + self.process.mode = ProcessMode.Repository + self.process.corpus = None + self.process.revision = self.rev + self.process.creator = self.superuser + self.process.save() + self.client.force_login(self.user) - -- - Arkindex + for role in [None, Role.Guest, Role.Contributor]: + with self.subTest(role=role): + self.rev.repo.memberships.filter(user=self.user).delete() + if role: + self.rev.repo.memberships.create(user=self.user, level=role.value) - """)) + with self.assertNumQueries(5): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - @override_settings(PUBLIC_HOSTNAME="http://ark.teklia/") - def test_process_finished_email_error( - self, async_notify, timezone_mock, s3_mock - ): - async_notify.side_effect = notify_process_completion - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - seal(timezone_mock) + def test_partial_update_running_task_state_stopping(self): + self.task1.state = State.Running + self.task1.save() + self.client.force_login(self.superuser) - self.process.finished = None - self.process.name = "wonderful process" - self.process.save() - self.process.tasks.update(agent=self.agent, state=State.Error) - self.process.tasks.filter(id=self.task3.id).update(state=State.Running) - with self.assertNumQueries(13): + with self.assertNumQueries(4): resp = self.client.patch( - reverse("api:task-details", args=[self.task3.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, ) self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] - self.assertEqual(message.subject, "Your process wonderful process finished with errors") - self.assertListEqual(message.to, [self.process.creator.email]) - self.assertEqual(message.body, dedent(f""" - Hello Test user, - - A process you created just finished. - ID: {self.process.id} - Overall state: Error + self.assertDictEqual(resp.json(), { + "id": str(self.task1.id), + "state": State.Stopping.value, + }) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, State.Stopping) - Tasks summary: - * {self.task1.slug}: Error - * {self.task2.slug}: Error - * {self.task3.slug}: Completed + def test_partial_update_non_running_task_state_stopping(self): + states = list(State) + states.remove(State.Running) + self.task1.save() + self.client.force_login(self.superuser) - You can see all the details and logs here: http://ark.teklia/process/{self.process.id}/0 + for state in states: + with self.subTest(state=state): + self.task1.state = state + self.task1.save() - -- - Arkindex + with self.assertNumQueries(3): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Stopping.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + resp.json(), + {"state": [f"Transition from state {state} to state Stopping is forbidden."]} + ) - """)) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, state) - @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.serializers.timezone") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - @override_settings(PUBLIC_HOSTNAME="http://ark.teklia/") - def test_process_finished_email_completed_third_run( - self, async_notify, timezone_mock, s3_mock - ): - async_notify.side_effect = notify_process_completion - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - expected_datetime = timezone.datetime(3000, 1, 1, 12).astimezone() - timezone_mock.now.return_value = expected_datetime - seal(timezone_mock) + def test_partial_update_final_task_state_pending(self): + self.task1.state = State.Completed + self.task1.save() + self.client.force_login(self.superuser) - self.process.finished = None - self.process.name = "ok" - self.process.save() - self.process.tasks.filter(id=self.task2.id).update(state=State.Completed, run=2) - self.process.tasks.filter(id=self.task3.id).update(agent=self.agent, state=State.Running, run=2) - with self.assertNumQueries(13): + with self.assertNumQueries(5): resp = self.client.patch( - reverse("api:task-details", args=[self.task3.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Pending.value}, ) self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertEqual(len(mail.outbox), 1) - message = mail.outbox[0] - self.assertEqual(message.subject, "Your process ok finished successfully") - self.assertListEqual(message.to, [self.process.creator.email]) - self.assertEqual(message.body, dedent(f""" - Hello Test user, - - A process you created just finished on run 2. - ID: {self.process.id} - Overall state: Completed + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, State.Pending) + self.assertIsNone(self.task1.agent) - Tasks summary: - * {self.task2.slug}: Completed - * {self.task3.slug}: Completed + def test_partial_update_non_final_task_state_pending(self): + states = set(State) - set(FINAL_STATES) + self.task1.save() + self.client.force_login(self.superuser) - You can see all the details and logs here: http://ark.teklia/process/{self.process.id}/2 + for state in states: + with self.subTest(state=state): + self.task1.state = state + self.task1.save() - -- - Arkindex + with self.assertNumQueries(3): + resp = self.client.patch( + reverse("api:task-update", args=[self.task1.id]), + data={"state": State.Pending.value}, + ) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - """)) + self.assertDictEqual( + resp.json(), + {"state": [f"Transition from state {state} to state Pending is forbidden."]} + ) + self.task1.refresh_from_db() + self.assertEqual(self.task1.state, state) - @patch("arkindex.ponos.signals.task_failure.send_robust") @patch("arkindex.project.aws.s3") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - def test_task_final_not_completed_triggers_signal( - self, async_notify, s3_mock, send_mock - ): + def test_task_logs_unicode_error(self, s3_mock): """ - Updating a task to a final state that is not `State.Completed` - should trigger the task_failure signal + Ensure the TaskLogs.latest property is able to handle sliced off Unicode characters. + Since we fetch the latest logs from S3 using `Range: bytes=-N`, sometimes the characters + can have a missing byte and cause a UnicodeDecodeError, such as U+00A0 (non-breaking space) + or U+00A9 (copyright symbol). """ - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - - states_expecting_signal = {State.Error, State.Failed, State.Stopped} - - for state in FINAL_STATES: - if state is State.Completed: - continue - with self.subTest(state=state): - if state is State.Stopped: - self.task1.state = State.Stopping - else: - self.task1.state = State.Running - self.task1.agent = self.agent - self.task1.save() - - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - if state in states_expecting_signal: - self.assertEqual(send_mock.call_count, 1) - _, kwargs = send_mock.call_args - self.assertDictEqual(kwargs, {"task": self.task1}) - else: - self.assertFalse(send_mock.called) - - send_mock.reset_mock() + with self.assertRaises(UnicodeDecodeError): + b"\xa0Failed successfully".decode("utf-8") - @patch("django.contrib.auth") - @patch("arkindex.project.aws.s3") - def test_task_update_gpu(self, s3_mock, auth_mock): - auth_mock.signals.user_logged_in = None s3_mock.Object.return_value.bucket_name = "ponos" s3_mock.Object.return_value.key = "somelog" s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"Some nice GPU task output...") + "Body": BytesIO(b"\xa0Failed successfully") } s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - - # All GPUs are available - self.assertFalse(GPU.objects.filter(tasks__isnull=False).exists()) - - # The task is running on a GPU - self.task1.gpu = self.agent.gpus.first() - self.task1.agent = self.agent - self.task1.state = State.Running - self.task1.save() - - # One GPU is assigned - self.assertTrue(GPU.objects.filter(tasks__isnull=False).exists()) + self.client.force_login(self.superuser) with self.assertNumQueries(4): - resp = self.client.get( - reverse("api:task-details", args=[self.task1.id]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - # Last ping is updated in the API call - self.agent.refresh_from_db() - - self.assertDictEqual( - resp.json(), - { - "id": str(self.task1.id), - "run": 0, - "depth": 0, - "extra_files": {}, - "slug": "initialisation", - "state": "running", - "parents": [], - "shm_size": None, - "logs": "Some nice GPU task output...", - "full_log": "http://somewhere", - "agent": { - "cpu_cores": 2, - "cpu_frequency": 1000000000, - "cpu_load": .1, - "farm": {"id": str(self.agent.farm_id), "name": "Wheat farm"}, - "gpus": [ - { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - { - "id": "f30d1407-92bb-484b-84b0-0b8bae41ca91", - "index": 1, - "name": "GPU2", - "ram_total": 8589934592, - }, - ], - "hostname": "ghostname", - "id": str(self.agent.id), - "last_ping": str_date(self.agent.last_ping), - "ram_load": 100000000, - "ram_total": 2000000000, - }, - "gpu": { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - }, - ) - - # Now let's complete that task, and check that the GPU is still assigned - with self.assertNumQueries(9): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": "completed"}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertDictEqual( - resp.json(), - { - "id": str(self.task1.id), - "run": 0, - "depth": 0, - "extra_files": {}, - "slug": "initialisation", - "state": "completed", - "parents": [], - "shm_size": None, - "logs": "", - "full_log": "http://somewhere", - "agent": { - "cpu_cores": 2, - "cpu_frequency": 1000000000, - "cpu_load": .1, - "farm": {"id": str(self.agent.farm_id), "name": "Wheat farm"}, - "gpus": [ - { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - { - "id": "f30d1407-92bb-484b-84b0-0b8bae41ca91", - "index": 1, - "name": "GPU2", - "ram_total": 8589934592, - }, - ], - "hostname": "ghostname", - "id": str(self.agent.id), - "last_ping": str_date(self.agent.last_ping), - "ram_load": 100000000, - "ram_total": 2000000000, - }, - "gpu": { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - }, - ) - - # All GPUs are available: no GPU has any task in an active state - self.assertFalse(GPU.objects.filter(tasks__state__in=ACTIVE_STATES).exists()) - - def test_update_task_from_agent_requires_login(self): - with self.assertNumQueries(0): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_update_task_from_agent_forbids_users(self): - self.client.force_login(self.user) - with self.assertNumQueries(2): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_task_from_agent_forbids_task(self): - with self.assertNumQueries(1): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_task_from_agent_unassigned(self): - self.assertNotEqual(self.task1.agent, self.agent) - - with self.assertNumQueries(2): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - @patch("arkindex.ponos.models.TaskLogs.latest", new_callable=PropertyMock) - def test_update_task_from_agent(self, short_logs_mock): - short_logs_mock.return_value = "" - self.process.tasks.filter(id=self.task1.id).update(state=State.Pending, agent=self.agent) - with self.assertNumQueries(5): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Running.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) + resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) self.assertEqual(resp.status_code, status.HTTP_200_OK) - data = resp.json() - self.assertEqual(data["id"], str(self.task1.id)) - self.assertEqual(data["state"], State.Running.value) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, State.Running) - @patch("arkindex.ponos.models.TaskLogs.latest", new_callable=PropertyMock) - def test_update_task_from_agent_completed_pends_children(self, short_logs_mock): + self.assertEqual(data["logs"], "�Failed successfully") + + @patch("arkindex.project.aws.s3") + def test_task_logs_errors(self, s3_mock): """ - Child tasks whose parents are all completed get set to pending as soon as the agent updates the last parent + Ensure the TaskLogs.latest property handles missing log files and returns an empty string, + or handles an InvalidRange error returned only by Ceph when the log file exists but has zero bytes. """ - short_logs_mock.return_value = "" + s3_mock.Object.return_value.bucket_name = "ponos" + s3_mock.Object.return_value.key = "somelog" + s3_mock.Object.return_value.get.side_effect = ClientError({"Error": {"Code": ""}}, "get_object") + s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" + seal(s3_mock) - self.task1.agent = self.agent - self.task2.agent = self.agent - self.task1.state = State.Running - self.task2.state = State.Running - self.task1.depth = 0 - self.task2.depth = 0 - self.task3.depth = 1 - self.task1.save() - self.task2.save() - self.task3.save() - self.task1.parents.clear() - self.task2.parents.clear() - self.task3.parents.set([self.task1, self.task2]) + self.client.force_login(self.superuser) - with self.assertNumQueries(9): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) + for code in ["NoSuchKey", "InvalidRange"]: + with self.subTest(code=code), self.assertNumQueries(4): + s3_mock.Object.return_value.get.side_effect.response["Error"]["Code"] = str(code) - self.task1.refresh_from_db() - self.task2.refresh_from_db() - self.task3.refresh_from_db() - self.assertEqual(self.task1.state, State.Completed) - self.assertEqual(self.task2.state, State.Running) - # Only one of the two parents is completed: nothing happens. - self.assertEqual(self.task3.state, State.Unscheduled) - - with self.assertNumQueries(9): - resp = self.client.put( - reverse("api:task-details", args=[self.task2.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task1.refresh_from_db() - self.task2.refresh_from_db() - self.task3.refresh_from_db() - self.assertEqual(self.task1.state, State.Completed) - self.assertEqual(self.task2.state, State.Completed) - # Both parents are completed, the child task is now pending - self.assertEqual(self.task3.state, State.Pending) - - def test_update_task_requires_login(self): - with self.assertNumQueries(0): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_update_task_requires_verified(self): - self.user.verified_email = False - self.user.save() - self.client.force_login(self.user) - - with self.assertNumQueries(2): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_task_forbids_agent(self): - with self.assertNumQueries(0): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_update_task_forbids_task(self): - with self.assertNumQueries(0): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_update_task_requires_process_admin_corpus(self): - self.process.creator = self.superuser - self.process.save() - self.corpus.public = False - self.corpus.save() - self.client.force_login(self.user) - - for role in [None, Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).delete() - if role: - self.corpus.memberships.create(user=self.user, level=role.value) - - with self.assertNumQueries(5): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_task_requires_process_admin_repo(self): - self.process.mode = ProcessMode.Repository - self.process.corpus = None - self.process.revision = self.rev - self.process.creator = self.superuser - self.process.save() - self.client.force_login(self.user) - - for role in [None, Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.rev.repo.memberships.filter(user=self.user).delete() - if role: - self.rev.repo.memberships.create(user=self.user, level=role.value) - - with self.assertNumQueries(5): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_running_task_state_stopping(self): - self.task1.state = State.Running - self.task1.save() - self.client.force_login(self.superuser) - - with self.assertNumQueries(4): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertDictEqual(resp.json(), { - "id": str(self.task1.id), - "state": State.Stopping.value, - }) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, State.Stopping) - - def test_update_non_running_task_state_stopping(self): - states = list(State) - states.remove(State.Running) - self.client.force_login(self.superuser) - - for state in states: - with self.subTest(state=state): - self.task1.state = state - self.task1.save() - - with self.assertNumQueries(3): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - resp.json(), - {"state": [f"Transition from state {state} to state Stopping is forbidden."]}, - ) - - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, state) - - def test_update_final_task_state_pending(self): - self.task1.state = State.Completed - self.task1.agent = self.agent - self.task1.gpu = self.agent.gpus.first() - self.task1.save() - self.client.force_login(self.superuser) - - with self.assertNumQueries(5): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Pending.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, State.Pending) - self.assertIsNone(self.task1.agent) - self.assertIsNone(self.task1.gpu) - - def test_update_non_final_task_state_pending(self): - states = set(State) - set(FINAL_STATES) - self.client.force_login(self.superuser) - - for state in states: - with self.subTest(state=state): - self.task1.state = state - self.task1.save() - - with self.assertNumQueries(3): - resp = self.client.put( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Pending.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual( - resp.json(), - {"state": [f"Transition from state {state} to state Pending is forbidden."]} - ) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, state) - - @patch("arkindex.ponos.models.TaskLogs.latest", new_callable=PropertyMock) - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - def test_partial_update_task_from_agent_allowed_states(self, notify_mock, short_logs_mock): - short_logs_mock.return_value = "" - self.task1.agent = self.agent - self.task1.save() - - cases = [ - (State.Unscheduled, State.Pending, 5), - (State.Pending, State.Running, 5), - (State.Pending, State.Error, 11), - (State.Running, State.Completed, 9), - (State.Running, State.Failed, 9), - (State.Running, State.Error, 9), - (State.Stopping, State.Stopped, 9), - (State.Stopping, State.Error, 9), - ] - - for from_state, to_state, query_count in cases: - with self.subTest(from_state=from_state, to_state=to_state): - self.task1.state = from_state - self.task1.save() - - with self.assertNumQueries(query_count): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": to_state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - data = resp.json() - self.assertEqual(data["id"], str(self.task1.id)) - self.assertEqual(data["state"], to_state.value) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, to_state) - - def test_partial_update_task_from_agent_forbidden_states(self): - self.task1.agent = self.agent - self.task1.save() - - cases = [ - (State.Unscheduled, State.Running), - (State.Unscheduled, State.Completed), - (State.Unscheduled, State.Failed), - (State.Unscheduled, State.Error), - (State.Unscheduled, State.Stopping), - (State.Unscheduled, State.Stopped), - (State.Pending, State.Unscheduled), - (State.Pending, State.Completed), - (State.Pending, State.Failed), - (State.Pending, State.Stopping), - (State.Pending, State.Stopped), - (State.Running, State.Unscheduled), - (State.Running, State.Pending), - (State.Running, State.Stopping), - (State.Running, State.Stopped), - (State.Stopping, State.Unscheduled), - (State.Stopping, State.Pending), - (State.Stopping, State.Running), - (State.Stopping, State.Completed), - (State.Stopping, State.Failed), - # Cannot go from one state to the same state - *((state, state) for state in State), - # Cannot go from a final state to anywhere - *((final_state, state) for final_state in FINAL_STATES for state in State), - ] - - for from_state, to_state in cases: - with self.subTest(from_state=from_state, to_state=to_state): - self.task1.state = from_state - self.task1.save() - - with self.assertNumQueries(2): - resp = self.client.put( - reverse("api:task-details", args=[self.task1.id]), - data={"state": to_state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual( - resp.json(), - {"state": [f"Transition from state {from_state} to state {to_state} is forbidden."]}, - ) - - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, from_state) - - def test_partial_update_task_from_agent_requires_login(self): - with self.assertNumQueries(0): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_partial_update_task_from_agent_forbids_users(self): - self.client.force_login(self.user) - with self.assertNumQueries(2): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_task_from_agent_forbids_task(self): - with self.assertNumQueries(1): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_task_from_agent_unassigned(self): - self.assertNotEqual(self.task1.agent, self.agent) - - with self.assertNumQueries(2): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - @patch("arkindex.ponos.models.TaskLogs.latest", new_callable=PropertyMock) - def test_partial_update_task_from_agent_completed_pends_children(self, short_logs_mock): - """ - Child tasks whose parents are all completed get set to pending as soon as the agent updates the last parent - """ - short_logs_mock.return_value = "" - - self.task1.agent = self.agent - self.task2.agent = self.agent - self.task1.depth = 0 - self.task2.depth = 0 - self.task3.depth = 1 - self.task1.state = State.Running - self.task2.state = State.Running - self.task3.state = State.Unscheduled - self.task1.save() - self.task2.save() - self.task3.save() - self.task1.parents.clear() - self.task2.parents.clear() - self.task3.parents.set([self.task1, self.task2]) - - with self.assertNumQueries(9): - resp = self.client.patch( - reverse("api:task-details", args=[self.task1.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task1.refresh_from_db() - self.task2.refresh_from_db() - self.task3.refresh_from_db() - self.assertEqual(self.task1.state, State.Completed) - self.assertEqual(self.task2.state, State.Running) - # Only one of the two parents is completed: nothing happens. - self.assertEqual(self.task3.state, State.Unscheduled) - self.assertIsNone(self.process.finished) - - with self.assertNumQueries(9): - resp = self.client.patch( - reverse("api:task-details", args=[self.task2.id]), - data={"state": State.Completed.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task1.refresh_from_db() - self.task2.refresh_from_db() - self.task3.refresh_from_db() - self.assertEqual(self.task1.state, State.Completed) - self.assertEqual(self.task2.state, State.Completed) - # Both parents are completed, the child task is now pending - self.assertEqual(self.task3.state, State.Pending) - self.assertIsNone(self.process.finished) - - def test_partial_update_task_requires_login(self): - with self.assertNumQueries(0): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_partial_update_task_requires_verified(self): - self.user.verified_email = False - self.user.save() - self.client.force_login(self.user) - - with self.assertNumQueries(2): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_task_forbids_agent(self): - with self.assertNumQueries(0): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_partial_update_task_forbids_task(self): - with self.assertNumQueries(0): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_partial_update_task_requires_process_admin_corpus(self): - self.process.creator = self.superuser - self.process.save() - self.corpus.public = False - self.corpus.save() - self.client.force_login(self.user) - - for role in [None, Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.corpus.memberships.filter(user=self.user).delete() - if role: - self.corpus.memberships.create(user=self.user, level=role.value) - - with self.assertNumQueries(5): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_task_requires_process_admin_repo(self): - self.process.mode = ProcessMode.Repository - self.process.corpus = None - self.process.revision = self.rev - self.process.creator = self.superuser - self.process.save() - self.client.force_login(self.user) - - for role in [None, Role.Guest, Role.Contributor]: - with self.subTest(role=role): - self.rev.repo.memberships.filter(user=self.user).delete() - if role: - self.rev.repo.memberships.create(user=self.user, level=role.value) - - with self.assertNumQueries(5): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_running_task_state_stopping(self): - self.task1.state = State.Running - self.task1.save() - self.client.force_login(self.superuser) - - with self.assertNumQueries(4): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertDictEqual(resp.json(), { - "id": str(self.task1.id), - "state": State.Stopping.value, - }) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, State.Stopping) - - def test_partial_update_non_running_task_state_stopping(self): - states = list(State) - states.remove(State.Running) - self.task1.save() - self.client.force_login(self.superuser) - - for state in states: - with self.subTest(state=state): - self.task1.state = state - self.task1.save() - - with self.assertNumQueries(3): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Stopping.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - resp.json(), - {"state": [f"Transition from state {state} to state Stopping is forbidden."]} - ) - - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, state) - - def test_partial_update_final_task_state_pending(self): - self.task1.state = State.Completed - self.task1.agent = self.agent - self.task1.save() - self.client.force_login(self.superuser) - - with self.assertNumQueries(5): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Pending.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, State.Pending) - self.assertIsNone(self.task1.agent) - - def test_partial_update_non_final_task_state_pending(self): - states = set(State) - set(FINAL_STATES) - self.task1.save() - self.client.force_login(self.superuser) - - for state in states: - with self.subTest(state=state): - self.task1.state = state - self.task1.save() - - with self.assertNumQueries(3): - resp = self.client.patch( - reverse("api:task-update", args=[self.task1.id]), - data={"state": State.Pending.value}, - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual( - resp.json(), - {"state": [f"Transition from state {state} to state Pending is forbidden."]} - ) - self.task1.refresh_from_db() - self.assertEqual(self.task1.state, state) - - @patch("arkindex.project.aws.s3") - def test_task_logs_unicode_error(self, s3_mock): - """ - Ensure the TaskLogs.latest property is able to handle sliced off Unicode characters. - Since we fetch the latest logs from S3 using `Range: bytes=-N`, sometimes the characters - can have a missing byte and cause a UnicodeDecodeError, such as U+00A0 (non-breaking space) - or U+00A9 (copyright symbol). - """ - with self.assertRaises(UnicodeDecodeError): - b"\xa0Failed successfully".decode("utf-8") - - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.return_value = { - "Body": BytesIO(b"\xa0Failed successfully") - } - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - - self.client.force_login(self.superuser) - with self.assertNumQueries(4): - resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - data = resp.json() - - self.assertEqual(data["logs"], "�Failed successfully") - - @patch("arkindex.project.aws.s3") - def test_task_logs_errors(self, s3_mock): - """ - Ensure the TaskLogs.latest property handles missing log files and returns an empty string, - or handles an InvalidRange error returned only by Ceph when the log file exists but has zero bytes. - """ - s3_mock.Object.return_value.bucket_name = "ponos" - s3_mock.Object.return_value.key = "somelog" - s3_mock.Object.return_value.get.side_effect = ClientError({"Error": {"Code": ""}}, "get_object") - s3_mock.meta.client.generate_presigned_url.return_value = "http://somewhere" - seal(s3_mock) - - self.client.force_login(self.superuser) - - for code in ["NoSuchKey", "InvalidRange"]: - with self.subTest(code=code), self.assertNumQueries(4): - s3_mock.Object.return_value.get.side_effect.response["Error"]["Code"] = str(code) - - resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.assertEqual(resp.json()["logs"], "") - - @patch("arkindex.ponos.serializers.check_agent_key") - @patch("arkindex.ponos.serializers.timezone") - def test_agent_create(self, timezone_mock, check_mock): - check_mock.return_value = True - timezone_mock.now.return_value = timezone.datetime(2000, 1, 1, 12).astimezone() - - resp = self.client.post( - reverse("api:agent-register"), - { - "hostname": "toastname", - "cpu_cores": 42, - "cpu_frequency": 1337e6, - "ram_total": 16e9, - "farm": str(self.wheat_farm.id), - "public_key": build_public_key(), - "derivation": "{:032x}".format(random.getrandbits(128)), - "gpus": [ - { - "id": "b6b8d7c1-c6bd-4de6-ae92-866a270be36f", - "name": "A", - "index": 0, - "ram_total": 512 * 1024 * 1024, - }, - { - "id": "14fe053a-8014-46a3-ad7b-99fa4389d74c", - "name": "B", - "index": 1, - "ram_total": 2 * 1024 * 1024 * 1024, - }, - ], - }, - format="json", - ) - - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - self.assertEqual(check_mock.call_count, 1) - data = resp.json() - agent = Agent.objects.get(id=data["id"]) - self.assertEqual(agent.hostname, "toastname") - self.assertEqual(agent.cpu_cores, 42) - self.assertEqual(agent.cpu_frequency, 1337e6) - self.assertEqual(agent.gpus.count(), 2) - gpu_a = agent.gpus.get(name="A") - gpu_b = agent.gpus.get(name="B") - self.assertEqual(gpu_a.index, 0) - self.assertEqual(gpu_b.index, 1) - self.assertEqual(gpu_a.ram_total, 512 * 1024 * 1024) - self.assertEqual(gpu_b.ram_total, 2 * 1024 * 1024 * 1024) - - self.assertIn("access_token", data) - self.assertIn("refresh_token", data) - del data["access_token"] - del data["refresh_token"] - - self.assertDictEqual( - data, - { - "id": str(agent.id), - "hostname": "toastname", - "cpu_cores": 42, - "cpu_frequency": int(1337e6), - "farm": str(self.wheat_farm.id), - "ram_total": 16_000_000_000, - "cpu_load": None, - "ram_load": None, - "last_ping": "2000-01-01T12:00:00Z", - "gpus": [ - { - "id": "b6b8d7c1-c6bd-4de6-ae92-866a270be36f", - "index": 0, - "name": "A", - "ram_total": 512 * 1024 * 1024, - }, - { - "id": "14fe053a-8014-46a3-ad7b-99fa4389d74c", - "index": 1, - "name": "B", - "ram_total": 2 * 1024 * 1024 * 1024, - }, - ], - }, - ) - - @patch("arkindex.ponos.serializers.check_agent_key") - @patch("arkindex.ponos.serializers.timezone") - def test_agent_update(self, timezone_mock, check_mock): - check_mock.return_value = True - timezone_mock.now.return_value = timezone.datetime(2000, 1, 1, 12).astimezone() - - # Still a POST, but on an existing agent - resp = self.client.post( - reverse("api:agent-register"), - { - "hostname": self.agent.hostname, - "cpu_cores": 12, - "cpu_frequency": 1e9, - "ram_total": 32e9, - "farm": str(self.wheat_farm.id), - "public_key": self.agent.public_key, - "derivation": "{:032x}".format(random.getrandbits(128)), - "gpus": [ - { - "id": "deadbeef-c6bd-4de6-ae92-866a270be36f", - "name": "new gpu", - "index": 0, - "ram_total": 32 * 1024 * 1024 * 1024, - }, - ], - }, - format="json", - ) - - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - self.assertEqual(check_mock.call_count, 1) - data = resp.json() - self.agent.refresh_from_db() - self.assertEqual(self.agent.hostname, "ghostname") - self.assertEqual(self.agent.cpu_cores, 12) - self.assertEqual(self.agent.cpu_frequency, 1e9) - self.assertEqual(self.agent.gpus.count(), 1) - gpu = self.agent.gpus.get(name="new gpu") - self.assertEqual(gpu.index, 0) - self.assertEqual(gpu.ram_total, 32 * 1024 * 1024 * 1024) - self.assertEqual(gpu.id, uuid.UUID("deadbeef-c6bd-4de6-ae92-866a270be36f")) - - self.assertIn("access_token", data) - self.assertIn("refresh_token", data) - del data["access_token"] - del data["refresh_token"] - - self.assertDictEqual( - data, - { - "cpu_cores": 12, - "cpu_frequency": 1000000000, - "cpu_load": .1, - "farm": str(self.wheat_farm.id), - "gpus": [ - { - "id": "deadbeef-c6bd-4de6-ae92-866a270be36f", - "index": 0, - "name": "new gpu", - "ram_total": 34359738368, - } - ], - "hostname": "ghostname", - "id": str(self.agent.id), - "last_ping": "2000-01-01T12:00:00Z", - "ram_load": 100000000, - "ram_total": 32000000000, - }, - ) - - @patch("arkindex.ponos.serializers.check_agent_key") - @patch("arkindex.ponos.serializers.timezone") - def test_agent_create_existing_gpu(self, timezone_mock, check_mock): - check_mock.return_value = True - timezone_mock.now.return_value = timezone.datetime(2000, 1, 1, 12).astimezone() - - resp = self.client.post( - reverse("api:agent-register"), - { - "hostname": "toastname", - "cpu_cores": 42, - "cpu_frequency": 1337e6, - "ram_total": 16e9, - "farm": str(self.wheat_farm.id), - "public_key": build_public_key(), - "derivation": "{:032x}".format(random.getrandbits(128)), - "gpus": [ - { - "id": str(self.gpu1.id), - "name": "A", - "index": 0, - "ram_total": 512 * 1024 * 1024, - }, - { - "id": str(self.gpu2.id), - "name": "B", - "index": 1, - "ram_total": 2 * 1024 * 1024 * 1024, - }, - ], - }, - format="json", - ) - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - self.assertEqual(check_mock.call_count, 1) - data = resp.json() - - self.gpu1.refresh_from_db() - self.gpu2.refresh_from_db() - self.assertEqual(self.gpu1.name, "A") - self.assertEqual(self.gpu2.name, "B") - self.assertEqual(self.gpu1.ram_total, 512 * 1024**2) - self.assertEqual(self.gpu2.ram_total, 2 * 1024**3) - - new_agent = Agent.objects.get(id=data["id"]) - self.assertEqual(self.gpu1.agent, new_agent) - self.assertEqual(self.gpu2.agent, new_agent) - - # Existing agent no longer has any assigned GPUs, since the new agent stole them - self.assertFalse(self.agent.gpus.exists()) - - @patch("arkindex.ponos.serializers.check_agent_key") - @patch("arkindex.ponos.serializers.timezone") - def test_agent_update_existing_gpu(self, timezone_mock, check_mock): - check_mock.return_value = True - timezone_mock.now.return_value = timezone.datetime(2000, 1, 1, 12).astimezone() - self.assertTrue(self.agent.gpus.count(), 2) - - # Still a POST, but on an existing agent - resp = self.client.post( - reverse("api:agent-register"), - { - "hostname": self.agent.hostname, - "cpu_cores": 12, - "cpu_frequency": 1e9, - "ram_total": 32e9, - "farm": str(self.wheat_farm.id), - "public_key": self.agent.public_key, - "derivation": "{:032x}".format(random.getrandbits(128)), - "gpus": [ - { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "name": "GPU1", - "index": 0, - "ram_total": 2 * 1024 * 1024 * 1024, - }, - { - "id": "f30d1407-92bb-484b-84b0-0b8bae41ca91", - "name": "GPU2", - "index": 1, - "ram_total": 8 * 1024 * 1024 * 1024, - }, - ], - }, - format="json", - ) - - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - - self.agent.refresh_from_db() - self.assertTrue(self.agent.gpus.count(), 2) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_agent_create_bad_seed(self): - # Build keys - server_public_key = ec.generate_private_key( - ec.SECP384R1(), - default_backend(), - ).public_key() - - agent_private_key = ec.generate_private_key( - ec.SECP384R1(), - default_backend(), - ) - agent_public_bytes = agent_private_key.public_key().public_bytes( - Encoding.PEM, - PublicFormat.SubjectPublicKeyInfo, - ) - - # Add 1 to the farm's seed - wrong_seed = "{:064x}".format(int(self.wheat_farm.seed, 16) + 1) - - # Perform derivation with the wrong seed - shared_key = agent_private_key.exchange(ec.ECDH(), server_public_key) - derived_key = HKDF( - algorithm=SHA256(), - backend=default_backend(), - length=32, - salt=None, - info=wrong_seed.encode("utf-8"), - ).derive(shared_key) - - resp = self.client.post( - reverse("api:agent-register"), - { - "hostname": "toastname", - "cpu_cores": 42, - "cpu_frequency": 1337e6, - "ram_total": 16e9, - "farm": str(self.wheat_farm.id), - "public_key": agent_public_bytes.decode("utf-8"), - "derivation": base64.b64encode(derived_key).decode("utf-8"), - "gpus": [], - }, - format="json", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - resp.json(), {"non_field_errors": ["Key verification failed"]} - ) - - def test_agent_actions_requires_token(self): - with self.assertNumQueries(0): - resp = self.client.get(reverse("api:agent-actions")) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_agent_actions_no_user(self): - self.client.force_login(self.superuser) - with self.assertNumQueries(0): - resp = self.client.get(reverse("api:agent-actions")) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_agent_actions_no_task(self): - with self.assertNumQueries(0): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_agent_actions_query_params_required(self): - with self.assertNumQueries(1): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - resp.json(), - { - "cpu_load": ["This query parameter is required."], - "ram_load": ["This query parameter is required."], - }, - ) - - def test_agent_actions_query_params_validation(self): - with self.assertNumQueries(1): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - data={"cpu_load": "high", "ram_load": "low"}, - ) - self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - resp.json(), - { - "cpu_load": ["A valid number is required."], - "ram_load": ["A valid number is required."], - }, - ) - - def test_agent_null_state(self): - """ - Agents with unknown CPU or RAM load are excluded by the assignation algorithm - """ - self.agent.cpu_load = None - self.agent.ram_load = None - self.agent.save() - pubkey = build_public_key() - second_agent = AgentUser.objects.create( - id=hashlib.md5(pubkey.encode("utf-8")).hexdigest(), - farm=self.wheat_farm, - hostname="new agent", - cpu_cores=2, - cpu_frequency=1e9, - public_key=pubkey, - ram_total=1e9, - last_ping=timezone.now(), - ) - with self.assertNumQueries(6): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Bearer {second_agent.token.access_token}", - data={"cpu_load": 1.9, "ram_load": 0.49}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertDictEqual(resp.json(), {"actions": []}) - - def test_agent_non_pending_actions(self): - """ - Only pending tasks may be retrieved as new actions - """ - Task.objects.filter(process__farm=self.agent.farm_id).update(state=State.Error) - with self.assertNumQueries(7): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - data={"cpu_load": 0.9, "ram_load": 0.49}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertDictEqual(resp.json(), {"actions": []}) - - def test_agent_no_stealing(self): - """ - An agent may not take another agent's tasks - """ - Task.objects.filter(process__farm=self.agent.farm_id).update(agent=self.agent, state=State.Pending) - pubkey = build_public_key() - agent2 = AgentUser.objects.create( - id=uuid.UUID(hashlib.md5(pubkey.encode("utf-8")).hexdigest()), - farm=self.wheat_farm, - hostname="agentorange", - cpu_cores=2, - cpu_frequency=1e9, - public_key=pubkey, - ram_total=2e9, - last_ping=timezone.now(), - cpu_load=.007, - ) - with self.assertNumQueries(7): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION=f"Bearer {agent2.token.access_token}", - data={"cpu_load": 0.9, "ram_load": 0.49}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - self.assertDictEqual(resp.json(), {"actions": []}) - - def test_agent_actions(self): - """ - Agent may retrieve one task using the API due to its resources limitations - """ - self.process.farm = self.agent.farm - self.process.save() - self.process.tasks.update(state=State.Pending) - now = timezone.now() - - with patch.object(api_tz, "now") as api_now_mock: - api_now_mock.return_value = now - with self.assertNumQueries(11): - resp = self.client.get( - reverse("api:agent-actions"), - HTTP_AUTHORIZATION="Bearer {}".format( - self.agent.token.access_token - ), - data={"cpu_load": 0.9, "ram_load": 0.49}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - self.agent.refresh_from_db() - self.assertEqual(self.agent._estimate_new_tasks_cost(), 0.99) - - self.assertDictEqual( - resp.json(), - { - "actions": [ - { - "action": "start_task", - "task_id": str(self.task1.id), - } - ] - }, - ) - # Agent load and last ping attributes have been updated - self.assertEqual( - (self.agent.cpu_load, self.agent.ram_load, self.agent.last_ping), - (0.9, 0.49, now), - ) - - def test_task_create_requires_task_auth(self): - response = self.client.post(reverse("api:task-create")) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertDictEqual( - response.json(), - {"detail": "Authentication credentials were not provided."} - ) - - def test_task_create_user_forbidden(self): - self.client.force_login(self.user) - response = self.client.post(reverse("api:task-create")) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertDictEqual( - response.json(), - {"detail": "Authentication credentials were not provided."} - ) - - def test_task_create_agent_forbidden(self): - self.client.force_login(self.user) - response = self.client.post(reverse("api:task-create"), HTTP_AUTHORIZATION=f"Bearer {self.agent.token}") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertDictEqual( - response.json(), - {"detail": "Authentication credentials were not provided."} - ) - - def test_task_create_empty_body(self): - response = self.client.post(reverse("api:task-create"), HTTP_AUTHORIZATION=f"Ponos {self.task1.token}") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - { - "image": ["This field is required."], - "parents": ["This field is required."], - "slug": ["This field is required."], - }, - ) - - def test_task_create_no_parent(self): - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "test_task", - "image": "registry.gitlab.com/test", - "parents": [], - }, - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), {"parents": ["This list may not be empty."]} - ) - - def test_task_create_distinct_workflows_on_parents(self): - process2 = Process.objects.create( - farm=self.wheat_farm, - mode=ProcessMode.Repository, - creator=self.superuser, - ) - task3 = process2.tasks.create( - run=0, - depth=1, - slug="task_parent", - image="registry.gitlab.com/test", - ) - - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "test_task", - "image": "registry.gitlab.com/test", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - }, - HTTP_AUTHORIZATION=f"Ponos {task3.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - { - "non_field_errors": [ - "All parents must be in the same process as the child task" - ] - }, - ) - - def test_task_create_distinct_runs_on_parents(self): - task3 = self.process.tasks.create( - run=1, - depth=1, - slug="task_parent", - image="registry.gitlab.com/test", - ) - - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "test_task", - "image": "registry.gitlab.com/test", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - }, - HTTP_AUTHORIZATION=f"Ponos {task3.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - { - "non_field_errors": [ - "All parents must have the same run in the given process" - ] - }, - ) - - def test_task_create_duplicate(self): - task = self.process.tasks.create( - run=0, - depth=3, - slug="sibling", - image="registry.gitlab.com/test", - ) - - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "sibling", - "image": "registry.gitlab.com/test", - "parents": [str(self.task1.id), str(self.task2.id)], - }, - HTTP_AUTHORIZATION=f"Ponos {task.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - { - "non_field_errors": [ - "A task with the `sibling` slug already exists in run 0." - ] - }, - ) - - def test_task_create(self): - task3 = self.process.tasks.create( - run=0, - depth=3, - slug="task_parent", - image="registry.gitlab.com/test", - ) - - with self.assertNumQueries(9): - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "test_task", - "image": "registry.gitlab.com/test", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - "command": "echo Test", - "env": {"test": "test", "test2": "test2"}, - }, - HTTP_AUTHORIZATION=f"Ponos {task3.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - new_task = self.process.tasks.get(slug="test_task") - - self.assertDictEqual( - response.json(), - { - "id": str(new_task.id), - "process_id": str(self.process.id), - "slug": "test_task", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - "image": "registry.gitlab.com/test", - "command": "echo Test", - "env": { - "ARKINDEX_API_CSRF_COOKIE": "arkindex.csrf", - "ARKINDEX_API_TOKEN": "deadbeefTestToken", - "ARKINDEX_API_URL": "http://localhost:8000/api/v1/", - "ARKINDEX_CORPUS_ID": str(self.process.corpus.id), - "ARKINDEX_PROCESS_ID": str(self.process.id), - "test": "test", - "test2": "test2", - "ARKINDEX_TASK_TOKEN": new_task.token, - }, - "run": 0, - "depth": 4, - "has_docker_socket": False, - }, - ) - - def test_task_create_has_docker_socket_true(self): - task3 = self.process.tasks.create( - run=0, - depth=3, - slug="task_parent", - image="registry.gitlab.com/test", - ) - - with self.assertNumQueries(9): - response = self.client.post( - reverse("api:task-create"), - data={ - "process_id": str(self.process.id), - "slug": "test_task", - "image": "registry.gitlab.com/test", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - "command": "echo Test", - "env": {"test": "test", "test2": "test2"}, - "has_docker_socket": True, - }, - HTTP_AUTHORIZATION=f"Ponos {task3.token}", - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - new_task = self.process.tasks.get(slug="test_task") - - self.assertDictEqual( - response.json(), - { - "id": str(new_task.id), - "process_id": str(self.process.id), - "slug": "test_task", - "parents": [str(self.task1.id), str(self.task2.id), str(task3.id)], - "image": "registry.gitlab.com/test", - "command": "echo Test", - "env": { - "ARKINDEX_API_CSRF_COOKIE": "arkindex.csrf", - "ARKINDEX_API_TOKEN": "deadbeefTestToken", - "ARKINDEX_API_URL": "http://localhost:8000/api/v1/", - "ARKINDEX_CORPUS_ID": str(self.process.corpus.id), - "ARKINDEX_PROCESS_ID": str(self.process.id), - "test": "test", - "test2": "test2", - "ARKINDEX_TASK_TOKEN": new_task.token, - }, - "run": 0, - "depth": 4, - "has_docker_socket": True, - }, - ) - - def test_retrieve_secret_requires_auth(self): - """ - Only agents or tasks may access a secret details - """ - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:secret-details", kwargs={"name": "abc"}) - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - self.assertDictEqual( - response.json(), {"detail": "Authentication credentials were not provided."} - ) - - def test_retrieve_secret_not_found(self): - """ - A 404 should be raised when no secret match query name - """ - with self.assertNumQueries(2): - response = self.client.get( - reverse( - "api:secret-details", kwargs={"name": "the_most_important_secret"} - ), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertDictEqual(response.json(), {"detail": "Not found."}) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_retrieve_secret_agent_forbidden(self): - """ - Agent should be able to retrieve a secret content as cleartext - """ - account_name = "bank_account/0001/private" - secret = Secret.objects.create( - name=account_name, - nonce=b"1337" * 4, - content=encrypt(b"1337" * 4, "1337$"), - ) - self.assertEqual(secret.content, b"\xc1\x81\xc0\xceo") - - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:secret-details", kwargs={"name": account_name}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_retrieve_secret_forbid_users(self): - account_name = "bank_account/0001/private" - secret = Secret.objects.create( - name=account_name, - nonce=b"1337" * 4, - content=encrypt(b"1337" * 4, "1337$"), - ) - self.assertEqual(secret.content, b"\xc1\x81\xc0\xceo") - - for user in [self.user, self.superuser]: - with self.subTest(user=user): - self.client.force_login(user) - with self.assertNumQueries(0): - response = self.client.get(reverse("api:secret-details", kwargs={"name": account_name})) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_task_retrieve_secret_requires_active_user(self): - account_name = "bank_account/0001/private" - secret = Secret.objects.create( - name=account_name, - nonce=b"1337" * 4, - content=encrypt(b"1337" * 4, "1337$"), - ) - self.assertEqual(secret.content, b"\xc1\x81\xc0\xceo") - - Process.objects.create( - creator=self.user, - mode=ProcessMode.Repository, - ) - self.user.is_active = False - self.user.save() - - with self.assertNumQueries(1): - response = self.client.get( - reverse("api:secret-details", kwargs={"name": account_name}), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - self.assertDictEqual( - response.json(), - {"detail": "User inactive or deleted."}, - ) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_task_retrieve_secret(self): - account_name = "bank_account/0001/private" - secret = Secret.objects.create( - name=account_name, - nonce=b"1337" * 4, - content=encrypt(b"1337" * 4, "1337$"), - ) - self.assertEqual(secret.content, b"\xc1\x81\xc0\xceo") - - corpus = Corpus.objects.create(name="Test corpus") - corpus.processes.create( - mode=ProcessMode.Workers, - creator=self.user, - ) - - with self.assertNumQueries(2): - response = self.client.get( - reverse("api:secret-details", kwargs={"name": account_name}), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual( - response.json(), - { - "id": str(secret.id), - "name": account_name, - "content": "1337$", - }, - ) - - def test_list_agents_requires_login(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:agents-state")) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_agents_agent_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:agents-state"), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_agents_task_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:agents-state"), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_agents_requires_verified(self): - self.user.verified_email = False - self.user.save() - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.get(reverse("api:agents-state")) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_agents(self): - """ - Lists agents from all farms with their status - """ - # Add tasks with different states on the agent - self.agent.tasks.bulk_create( - [ - Task( - run=0, - depth=0, - process=self.process, - slug=state.value, - state=state, - agent=self.agent, - ) - for state in State - ] - ) - self.client.force_login(self.superuser) - with self.assertNumQueries(6): - response = self.client.get(reverse("api:agents-state")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - data = response.json() - self.assertEqual(data["count"], 1) - agent_state = data["results"][0] - del agent_state["last_ping"] - self.assertDictEqual( - agent_state, - { - "id": str(self.agent.id), - "active": True, - "cpu_cores": 2, - "cpu_frequency": 1000000000, - "cpu_load": .1, - "farm": { - "id": str(self.wheat_farm.id), - "name": "Wheat farm", - }, - "gpus": [ - { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - { - "id": "f30d1407-92bb-484b-84b0-0b8bae41ca91", - "index": 1, - "name": "GPU2", - "ram_total": 8589934592, - }, - ], - "hostname": "ghostname", - "ram_load": 100000000, - "ram_total": 2000000000, - "running_tasks_count": 1, - }, - ) - - def test_retrieve_agent_requires_login(self): - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:agent-details", kwargs={"pk": str(self.agent.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_retrieve_agent_requires_verified(self): - self.user.verified_email = False - self.user.save() - self.client.force_login(self.user) - - with self.assertNumQueries(2): - response = self.client.get( - reverse("api:agent-details", kwargs={"pk": str(self.agent.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_retrieve_agent_details(self): - """ - The view returns an agents with its details and associated running tasks - """ - # Add tasks with different states on the agent - Task.objects.bulk_create( - [ - Task( - run=0, - depth=0, - process=self.process, - slug=state.value, - state=state, - agent=self.agent, - ) - for state in State - ] - ) - running_task = self.agent.tasks.get(state=State.Running) - self.client.force_login(self.user) - - with self.assertNumQueries(6): - response = self.client.get( - reverse("api:agent-details", kwargs={"pk": str(self.agent.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - data = response.json() - del data["last_ping"] - self.assertDictEqual( - data, - { - "id": str(self.agent.id), - "active": True, - "cpu_cores": 2, - "cpu_frequency": 1000000000, - "cpu_load": .1, - "farm": { - "id": str(self.wheat_farm.id), - "name": "Wheat farm", - }, - "gpus": [ - { - "id": "108c6524-c63a-4811-bbed-9723d32a0688", - "index": 0, - "name": "GPU1", - "ram_total": 2147483648, - }, - { - "id": "f30d1407-92bb-484b-84b0-0b8bae41ca91", - "index": 1, - "name": "GPU2", - "ram_total": 8589934592, - }, - ], - "hostname": "ghostname", - "ram_load": 100000000, - "ram_total": 2000000000, - "running_tasks": [ - { - "id": str(running_task.id), - "run": 0, - "depth": 0, - "parents": [], - "slug": "running", - "state": "running", - "shm_size": None, - } - ], - }, - ) - - def test_retrieve_agent_agent_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:agent-details", kwargs={"pk": str(self.agent.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_retrieve_agent_task_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get( - reverse("api:agent-details", kwargs={"pk": str(self.agent.id)}), - HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", - ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_farms_requires_login(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:farm-list")) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_farms_requires_verified(self): - self.user.verified_email = False - self.user.save() - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.get(reverse("api:farm-list")) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_farms_agent_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:farm-list"), HTTP_AUTHORIZATION=f"Bearer {self.agent.token}") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_farms_task_forbidden(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:farm-list"), HTTP_AUTHORIZATION=f"Ponos {self.task1.token}") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_list_farms(self): - self.client.force_login(self.superuser) - - with self.assertNumQueries(4): - response = self.client.get(reverse("api:farm-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual( - response.json(), - { - "count": 2, - "number": 1, - "previous": None, - "next": None, - "results": [ - {"id": str(self.default_farm.id), "name": "Default farm"}, - {"id": str(self.wheat_farm.id), "name": "Wheat farm"}, - ], - }, - ) - - def test_list_farms_guest(self): - self.client.force_login(self.user) - self.assertFalse(self.default_farm.is_available(self.user)) - self.assertTrue(self.wheat_farm.is_available(self.user)) - # Accessing memberships causes content types to be cached, - # so clear again to get the amount of queries for the worst case - self.clear_caches() - - with self.assertNumQueries(6): - response = self.client.get(reverse("api:farm-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual( - response.json(), - { - "count": 1, - "number": 1, - "previous": None, - "next": None, - "results": [ - {"id": str(self.wheat_farm.id), "name": "Wheat farm"}, - ], - }, - ) - - @override_settings(PONOS_PRIVATE_KEY=PONOS_PRIVATE_KEY) - def test_public_key(self): - expected = load_pem_private_key( - PONOS_PRIVATE_KEY.read_bytes(), - password=None, - backend=default_backend(), - ).public_key().public_bytes( - Encoding.PEM, - PublicFormat.SubjectPublicKeyInfo, - ) - - with self.assertNumQueries(0): - response = self.client.get(reverse("api:public-key")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(response.content, expected) - - def test_agent_refresh(self): - with self.assertNumQueries(0): - response = self.client.post( - reverse("api:agent-token-refresh"), - {"refresh": str(self.agent.token)}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - data = response.json() - self.assertCountEqual(list(data.keys()), ["access", "refresh"]) - access_token = AccessToken(data["access"]) - refresh_token = RefreshToken(data["refresh"]) - access_token.verify() - refresh_token.verify() - self.assertEqual(access_token.payload["agent_id"], str(self.agent.id)) - self.assertEqual(refresh_token.payload["agent_id"], str(self.agent.id)) + resp = self.client.get(reverse("api:task-details", args=[self.task1.id])) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.json()["logs"], "") diff --git a/arkindex/ponos/tests/test_artifacts_api.py b/arkindex/ponos/tests/test_artifacts_api.py index 76cf4e6659..5e3266f7ed 100644 --- a/arkindex/ponos/tests/test_artifacts_api.py +++ b/arkindex/ponos/tests/test_artifacts_api.py @@ -1,18 +1,13 @@ -import hashlib -import uuid +from unittest import expectedFailure from django.conf import settings from django.test import override_settings from django.urls import reverse -from django.utils import timezone from rest_framework import status from arkindex.documents.models import Corpus -from arkindex.ponos.authentication import AgentUser -from arkindex.ponos.models import Farm from arkindex.process.models import Process, ProcessMode, Repository from arkindex.project.tests import FixtureAPITestCase -from arkindex.project.tools import build_public_key from arkindex.users.models import Right, Role, User @@ -22,18 +17,6 @@ class TestAPI(FixtureAPITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.wheat_farm = Farm.objects.get(name="Wheat farm") - pubkey = build_public_key() - cls.agent = AgentUser.objects.create( - id=uuid.UUID(hashlib.md5(pubkey.encode("utf-8")).hexdigest()), - farm=cls.wheat_farm, - hostname="ghostname", - cpu_cores=2, - cpu_frequency=1e9, - public_key=pubkey, - ram_total=2e9, - last_ping=timezone.now(), - ) cls.repo = Repository.objects.first() cls.repository_process = Process.objects.create( @@ -57,7 +40,6 @@ class TestAPI(FixtureAPITestCase): mode=ProcessMode.Workers, creator=new_user, corpus=new_corpus, - farm=cls.wheat_farm, ) cls.process2.run() cls.task3 = cls.process2.tasks.first() @@ -87,6 +69,7 @@ class TestAPI(FixtureAPITestCase): response = self.client.get(reverse("api:task-artifacts", args=[self.task1.id])) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_list_requires_process_guest(self): self.process.creator = self.superuser self.process.save() @@ -108,7 +91,7 @@ class TestAPI(FixtureAPITestCase): with self.subTest(role=role): self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get(reverse("api:task-artifacts", args=[self.task1.id])) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -145,7 +128,7 @@ class TestAPI(FixtureAPITestCase): membership.level = role.value membership.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:task-artifacts", args=[self.task1.id])) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -203,38 +186,6 @@ class TestAPI(FixtureAPITestCase): ], ) - def test_list_agent(self): - with self.assertNumQueries(3): - response = self.client.get( - reverse("api:task-artifacts", args=[self.task1.id]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertListEqual( - response.json(), - [ - { - "content_type": "application/json", - "created": self.artifact1.created.isoformat().replace("+00:00", "Z"), - "id": str(self.artifact1.id), - "path": "path/to/file.json", - "s3_put_url": None, - "size": 42, - "updated": self.artifact1.updated.isoformat().replace("+00:00", "Z"), - }, - { - "content_type": "text/plain", - "created": self.artifact2.created.isoformat().replace("+00:00", "Z"), - "id": str(self.artifact2.id), - "path": "some/text.txt", - "s3_put_url": None, - "size": 1337, - "updated": self.artifact2.updated.isoformat().replace("+00:00", "Z"), - }, - ], - ) - def test_list_other_task_no_access(self): with self.assertNumQueries(2): response = self.client.get( @@ -244,7 +195,7 @@ class TestAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_other_task_with_access(self): - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get( reverse("api:task-artifacts", args=[self.task1.id]), HTTP_AUTHORIZATION=f"Ponos {self.task2.token}", @@ -311,60 +262,8 @@ class TestAPI(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - @override_settings(PONOS_ARTIFACT_MAX_SIZE=99999) - def test_create_unassigned_agent(self): - self.assertNotEqual(self.task1.agent, self.agent) - - with self.assertNumQueries(2): - response = self.client.post( - reverse("api:task-artifacts", args=[self.task1.id]), - data={"path": "some/path.txt", "content_type": "text/plain", "size": 1000}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @override_settings(PONOS_ARTIFACT_MAX_SIZE=99999) - def test_create_assigned_agent(self): - self.task1.agent = self.agent - self.task1.save() - - with self.assertNumQueries(5): - response = self.client.post( - reverse("api:task-artifacts", args=[self.task1.id]), - data={"path": "some/path.txt", "content_type": "text/plain", "size": 1000}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Check response has a valid S3 put URL, without matching the parameters in the querystring - data = response.json() - s3_put_url = data.get("s3_put_url") - self.assertIsNotNone(s3_put_url) - del data["s3_put_url"] - self.assertTrue( - s3_put_url.startswith( - f"http://s3/ponos-artifacts/{self.task1.id}/some/path.txt" - ) - ) - - # An artifact has been created - artifact = self.task1.artifacts.get(path="some/path.txt") - - self.assertDictEqual( - response.json(), - { - "content_type": "text/plain", - "created": artifact.created.isoformat().replace("+00:00", "Z"), - "id": str(artifact.id), - "path": "some/path.txt", - "size": 1000, - "updated": artifact.updated.isoformat().replace("+00:00", "Z"), - }, - ) - @override_settings(PONOS_ARTIFACT_MAX_SIZE=99999) def test_create_unique_path(self): - self.task1.agent = self.agent self.task1.save() self.task1.artifacts.create( path="some/path.txt", @@ -380,9 +279,10 @@ class TestAPI(FixtureAPITestCase): "content_type": "text/plain", "size": 10243, }, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( response.json(), {"path": ["An artifact with this path already exists"]} @@ -390,7 +290,6 @@ class TestAPI(FixtureAPITestCase): @override_settings() def test_create_size_limits(self): - self.task1.agent = self.agent self.task1.save() params = [ @@ -420,13 +319,13 @@ class TestAPI(FixtureAPITestCase): "content_type": "text/plain", "size": size, }, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", + HTTP_AUTHORIZATION=f"Ponos {self.task1.token}", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"size": [expected_error]}) def test_create_other_task(self): - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.post( reverse("api:task-artifacts", args=[self.task1.id]), data={ @@ -491,6 +390,7 @@ class TestAPI(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_download_requires_process_guest(self): self.corpus.memberships.filter(user=self.user).delete() self.corpus.public = False @@ -510,7 +410,7 @@ class TestAPI(FixtureAPITestCase): with self.subTest(role=role): self.corpus.memberships.filter(user=self.user).update(level=role.value) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get( reverse("api:task-artifact-download", args=[self.task1.id, "path/to/file.json"]), ) @@ -535,7 +435,7 @@ class TestAPI(FixtureAPITestCase): membership.level = role.value membership.save() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get( reverse("api:task-artifact-download", args=[task.id, "path/to/file.json"]), ) @@ -548,32 +448,6 @@ class TestAPI(FixtureAPITestCase): ) ) - def test_download_not_found(self): - with self.assertNumQueries(2): - response = self.client.get( - reverse("api:task-artifact-download", args=[self.task1.id, "nope.xxx"]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_download_agent(self): - # Being assigned to the task is not required - self.assertNotEqual(self.task1.agent, self.agent) - - with self.assertNumQueries(2): - response = self.client.get( - reverse("api:task-artifact-download", args=[self.task1.id, "path/to/file.json"]), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - - self.assertTrue(response.has_header("Location")) - self.assertTrue( - response["Location"].startswith( - f"http://s3/ponos-artifacts/{self.task1.id}/path/to/file.json" - ) - ) - def test_download_task(self): with self.assertNumQueries(2): response = self.client.get( @@ -590,7 +464,7 @@ class TestAPI(FixtureAPITestCase): ) def test_download_other_task_with_access(self): - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.get( reverse("api:task-artifact-download", args=[self.task1.id, "path/to/file.json"]), HTTP_AUTHORIZATION=f"Ponos {self.task2.token}", diff --git a/arkindex/ponos/tests/test_keys.py b/arkindex/ponos/tests/test_keys.py deleted file mode 100644 index f1d397e970..0000000000 --- a/arkindex/ponos/tests/test_keys.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -import tempfile - -import cryptography -from django.test import TestCase, override_settings - -from arkindex.ponos.keys import gen_private_key, load_private_key - -BAD_KEY = """-----BEGIN RSA PRIVATE KEY----- -MCoCAQACBGvoyx0CAwEAAQIEUg2X0QIDAMUbAgMAjCcCAmr9AgJMawICKvo= ------END RSA PRIVATE KEY-----""" - - -@override_settings(PONOS_PRIVATE_KEY=None) -class KeysTestCase(TestCase): - """ - Test ECDH keys - """ - - def test_no_key(self): - """ - No key by default with those settings - """ - with self.settings(DEBUG=True): - # On debug, that works, but with a warning - self.assertIsNone(load_private_key()) - - with self.settings(DEBUG=False): - # On prod, that fails - with self.assertRaisesRegex( - Exception, r"Missing setting PONOS_PRIVATE_KEY" - ): - load_private_key() - - with self.settings(DEBUG=True, PONOS_PRIVATE_KEY="/tmp/nope"): - # On debug, that works, but with a warning - self.assertIsNone(load_private_key()) - - with self.settings(DEBUG=False, PONOS_PRIVATE_KEY="/tmp/nope"): - # On prod, that fails - with self.assertRaisesRegex(Exception, r"Invalid PONOS_PRIVATE_KEY path"): - load_private_key() - - # Test with a valid RSA key (not ECDH) - _, path = tempfile.mkstemp() - open(path, "w").write(BAD_KEY) - with self.settings(DEBUG=True, PONOS_PRIVATE_KEY=path): - # On debug, that fails too ! - with self.assertRaisesRegex(Exception, r"not an ECDH"): - load_private_key() - - with self.settings(DEBUG=False, PONOS_PRIVATE_KEY=path): - # On prod, that fails - with self.assertRaisesRegex(Exception, r"not an ECDH"): - load_private_key() - os.unlink(path) - - def test_private_key(self): - """ - Test private key writing and loading - """ - - # Generate some key - _, path = tempfile.mkstemp() - gen_private_key(path) - key = open(path).read().splitlines() - self.assertTrue(len(key) > 2) - self.assertEqual(key[0], "-----BEGIN PRIVATE KEY-----") - self.assertEqual(key[-1], "-----END PRIVATE KEY-----") - - # Load it through settings - with self.settings(PONOS_PRIVATE_KEY=path): - priv = load_private_key() - self.assertTrue( - isinstance( - priv, - cryptography.hazmat.backends.openssl.ec._EllipticCurvePrivateKey, - ) - ) - self.assertTrue(priv.key_size > 256) - - # When messed up, nothing works - with open(path, "w") as f: - f.seek(100) - f.write("coffee") - with self.settings(PONOS_PRIVATE_KEY=path): - with self.assertRaisesRegex(Exception, r"Could not deserialize key data"): - load_private_key() - os.unlink(path) diff --git a/arkindex/ponos/tests/test_models.py b/arkindex/ponos/tests/test_models.py index ed04ea1071..f8e884911f 100644 --- a/arkindex/ponos/tests/test_models.py +++ b/arkindex/ponos/tests/test_models.py @@ -1,12 +1,9 @@ -import tempfile from unittest.mock import patch -from django.core.exceptions import ValidationError from django.db.models import prefetch_related_objects -from django.test import override_settings from django.utils import timezone -from arkindex.ponos.models import FINAL_STATES, Agent, Farm, Secret, State, build_aes_cipher, encrypt +from arkindex.ponos.models import FINAL_STATES, State from arkindex.process.models import ProcessMode from arkindex.project.tests import FixtureAPITestCase @@ -15,39 +12,14 @@ class TestModels(FixtureAPITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.farm = Farm.objects.create(name="Cryptominers") cls.process = cls.corpus.processes.create( creator=cls.user, mode=ProcessMode.Workers, - farm=cls.farm, ) cls.process.run() cls.task1 = cls.process.tasks.first() cls.nonce = b"42" + b"0" * 14 - def setUp(self): - self.agent = Agent.objects.create( - farm=self.farm, - hostname="roastname", - cpu_cores=100, - cpu_frequency=1e9, - public_key="Mamoswine", - ram_total=10e9, - last_ping=timezone.now(), - ) - self.agent.gpus.create( - id="108c6524-c63a-4811-bbed-9723d32a0688", - name="GPU1", - index=0, - ram_total=2 * 1024 * 1024 * 1024, - ) - self.agent.gpus.create( - id="f30d1407-92bb-484b-84b0-0b8bae41ca91", - name="GPU2", - index=1, - ram_total=8 * 1024 * 1024 * 1024, - ) - def test_is_final(self): for state in State: self.task1.state = state @@ -67,72 +39,6 @@ class TestModels(FixtureAPITestCase): self.process.is_final, msg="{} should not be final".format(state) ) - def test_delete_agent_non_final(self): - """ - Agent deletion should be prevented when it has non-final tasks - """ - self.task1.agent = self.agent - self.task1.state = State.Pending - self.task1.save() - with self.assertRaisesRegex(ValidationError, "non-final"): - self.agent.delete() - - self.task1.state = State.Error - self.task1.save() - # Should no longer fail - self.agent.delete() - - self.task1.refresh_from_db() - self.assertIsNone(self.task1.agent) - - @override_settings(PONOS_PRIVATE_KEY=None) - def test_aes_missing_key(self): - with self.assertRaisesRegex(Exception, r"Missing a PONOS_PRIVATE_KEY"): - build_aes_cipher(nonce=self.nonce) - - @patch("arkindex.ponos.models.settings") - def test_build_aes_cipher(self, settings_mock): - """ - AES encryption key should be a derivate of the Ponos server secret key - """ - _, path = tempfile.mkstemp() - settings_mock.PONOS_PRIVATE_KEY = path - with open(path, "wb") as f: - f.write(b"pikachu") - cipher = build_aes_cipher(nonce=self.nonce) - self.assertEqual(cipher.encryptor().update(b"hey"), b"lM\x8d") - with open(path, "wb") as f: - f.write(b"bulbasaur") - cipher = build_aes_cipher(nonce=self.nonce) - self.assertNotEqual(cipher.encryptor().update(b"hey"), b"lM\x8d") - - @patch("arkindex.ponos.models.settings") - def test_secret_encrypt_decrypt(self, settings_mock): - _, path = tempfile.mkstemp() - settings_mock.PONOS_PRIVATE_KEY = path - with open(path, "wb") as f: - f.write(b"pikachu") - secret = Secret( - name="Test secret", - nonce=self.nonce, - content=encrypt(self.nonce, "secret_m3ssage"), - ) - self.assertEqual(secret.content, b"wM\x97\n\xadS\x13\x8a\x89&ZF\xbd\xee") - self.assertEqual(secret.decrypt(), "secret_m3ssage") - - def test_agent_estimate_new_tasks_cost(self): - """ - Agent has 100 cores and 10GB of RAM - One task is estimated to use 1 CPU (1%) and 1GB of RAM (10%) - """ - self.agent.cpu_load = 50 - self.agent.ram_load = 0.35 - self.agent.save() - # The CPU will define the agent load for the first task reaching 51% occupancy - self.assertEqual(self.agent._estimate_new_tasks_cost(tasks=1), 0.51) - # For the second task, the RAM will reach 55% occupancy overtaking the CPU load (52%) - self.assertEqual(self.agent._estimate_new_tasks_cost(tasks=2), 0.55) - def test_requires_gpu(self): """ Check the GPU generated requirements rules diff --git a/arkindex/ponos/tests/test_rq_tasks.py b/arkindex/ponos/tests/test_rq_tasks.py new file mode 100644 index 0000000000..f029afb91f --- /dev/null +++ b/arkindex/ponos/tests/test_rq_tasks.py @@ -0,0 +1,53 @@ +from unittest.mock import call, patch + +from django.test import override_settings + +from arkindex.ponos.models import Farm +from arkindex.process.models import ProcessMode, WorkerVersion +from arkindex.project.tests import FixtureAPITestCase + + +class TestModels(FixtureAPITestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.farm = Farm.objects.create(name="Farm") + cls.process = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + corpus=cls.corpus, + farm=cls.farm, + ) + cls.worker_version1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_version2 = WorkerVersion.objects.get(worker__slug="dla") + cls.run1 = cls.process.worker_runs.create(version=cls.worker_version1, parents=[]) + cls.run2 = cls.process.worker_runs.create(version=cls.worker_version2, parents=[cls.run1.id]) + + @override_settings(PONOS_RQ_EXECUTION=True) + @patch("arkindex.ponos.tasks.run_task_rq.delay") + def test_run_process_schedules_tasks(self, run_task_mock): + run_task_mock.side_effect = ["init_job", "job1", "job2"] + # on_commit will not be called automatically as savepoints are used for tests transactions + with self.captureOnCommitCallbacks(execute=True): + self.process.run() + init, t1, t2 = self.process.tasks.order_by("depth") + self.assertEqual(run_task_mock.call_count, 3) + call1, call2, call3 = run_task_mock.call_args_list + self.assertEqual(call1, call(init)) + self.assertTupleEqual(call2.args, (t1,)) + self.assertListEqual(list(call2.kwargs.keys()), ["depends_on"]) + task1_depends = call2.kwargs["depends_on"] + self.assertEqual(vars(task1_depends), { + "dependencies": ["init_job"], + "allow_failure": True, + "enqueue_at_front": False, + }) + self.assertTupleEqual(call3.args, (t2,)) + self.assertListEqual(list(call3.kwargs.keys()), ["depends_on"]) + task2_depends = call3.kwargs["depends_on"] + self.assertEqual(vars(task2_depends), { + "dependencies": ["job1"], + "allow_failure": True, + "enqueue_at_front": False, + }) diff --git a/arkindex/ponos/tests/test_tasks_attribution.py b/arkindex/ponos/tests/test_tasks_attribution.py deleted file mode 100644 index 1d09619a82..0000000000 --- a/arkindex/ponos/tests/test_tasks_attribution.py +++ /dev/null @@ -1,481 +0,0 @@ -import uuid -from datetime import timedelta -from unittest.mock import patch - -from django.utils import timezone - -from arkindex.ponos.models import ACTIVE_STATES, FINAL_STATES, Agent, Farm, State, Task -from arkindex.ponos.models import timezone as model_tz -from arkindex.process.models import Process, ProcessMode, WorkerVersion, WorkerVersionState -from arkindex.project.tests import FixtureTestCase - - -class TasksAttributionTestCase(FixtureTestCase): - """ - Ponos server distribute tasks equally among agents. - """ - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.farm = Farm.objects.get(name="Wheat farm") - cls.process = Process.objects.create(creator=cls.superuser, mode=ProcessMode.Repository, farm=cls.farm) - # Remove all task fixtures so we can fully control task assignation - WorkerVersion.objects.filter(state=WorkerVersionState.Available).update(state=WorkerVersionState.Error) - Task.objects.all().delete() - - def _run_tasks(self, tasks): - """ - Mark a list of tasks as running - """ - for t in tasks: - t.state = State.Running - t.save() - - def _build_agent(self, **kwargs): - """ - Creates an agent - Default values may be overridden if passed as kwargs - """ - params = { - "hostname": "test_host", - "cpu_cores": 2, - "cpu_frequency": 4.2e9, - "public_key": "", - "farm": self.farm, - "ram_total": 2e9, - "last_ping": timezone.now(), - "ram_load": 0.49, - "cpu_load": 0.99, - } - params.update(kwargs) - return Agent.objects.create(**params) - - def _add_pending_tasks(self, number, slug_ext="", **kwargs): - """ - Creates pending tasks on the system - """ - params = { - "run": 0, - "depth": 0, - "process": self.process, - "state": State.Pending, - } - params.update(kwargs) - return Task.objects.bulk_create( - Task(**params, slug=f"slug{slug_ext}_{i}") for i in range(1, number + 1) - ) - - def _active_agent(self, agent): - from arkindex.ponos.models import AGENT_TIMEOUT - - return timezone.now() - agent.last_ping < AGENT_TIMEOUT - - def test_distribute_tasks(self): - """ - Multiple tasks are attributed to 3 registered agents with the same capacity - """ - agent_1, agent_2, agent_3 = [ - # Build three agents with 10 free CPU cores and 10Go of available RAM - self._build_agent( - hostname=f"agent_{i}", - cpu_cores=11, - ram_total=20e9, - ) - for i in range(1, 4) - ] - self.assertEqual( - [a.hostname for a in Agent.objects.all() if self._active_agent(a) is True], - ["agent_1", "agent_2", "agent_3"], - ) - # Add 9 pending tasks to the system - self._add_pending_tasks(9) - - # As agents are registered, they should retrieve 3 tasks each - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 3) - self._run_tasks(tasks) - - # Agent 1 claims tasks before agent 2 but has a too high load to retrieve any task - agent_1.ram_load = 0.75 - agent_1.cpu_load = 3.99 - agent_1.save() - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 0) - bg_tasks = agent_2.next_tasks() - self.assertEqual(len(bg_tasks), 3) - - # Agent 1 claims tasks before agent 3 - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 0) - tasks = agent_3.next_tasks() - self.assertEqual(len(tasks), 3) - - # No agent updated its state before agent_2 retrieved its tasks - self.assertEqual(len(set(t.id for t in [*bg_tasks, *tasks])), 6) - - self._run_tasks(bg_tasks) - self._run_tasks(tasks) - self.assertEqual(Task.objects.filter(state=State.Pending).count(), 0) - - def test_distribute_tasks_asymetric(self): - """ - Use case when two agents with different capacity are attributed - tasks equivalent to a third of the system capacity - """ - # Agent 1 has 10 free "slots" - agent_1 = self._build_agent(hostname="agent_1", cpu_cores=11, ram_total=20e9) - # Agent 2 has only 3 free "slots" - agent_2 = self._build_agent(hostname="agent_2", cpu_cores=4, ram_total=6e9) - # Add 5 pending tasks to the system - self._add_pending_tasks(5) - - # The best strategy is to attribute 4 tasks to the first agent and 1 to the second - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 4) - self._run_tasks(tasks) - - agent_1.ram_load = 0.7 - agent_1.cpu_load = 3.99 - agent_1.save() - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 0) - tasks = agent_2.next_tasks() - self.assertEqual(len(tasks), 1) - self._run_tasks(tasks) - self.assertEqual(Task.objects.filter(state=State.Pending).count(), 0) - - def test_distribute_tasks_determinist(self): - """ - Tasks should be distributed in a determinist way depending - on the overall load and pending tasks queue - Tests that an agent polling task twice retrieves the same tasks - """ - _, _, agent = [ - self._build_agent( - hostname=f"agent_{i}", - cpu_cores=11, - ram_total=20e9, - ) - for i in range(1, 4) - ] - self._add_pending_tasks(9) - self.assertEqual( - [a.hostname for a in Agent.objects.all() if self._active_agent(a) is True], - ["agent_1", "agent_2", "agent_3"], - ) - - tasks = agent.next_tasks() - self.assertEqual(len(tasks), 3) - - new_tasks = agent.next_tasks() - self.assertCountEqual(tasks, new_tasks) - - @patch("arkindex.ponos.models.AGENT_TIMEOUT", timedelta(seconds=1)) - def test_non_active_agent(self): - """ - If an agent does not respond, task should be attributed to other running agents - """ - agent_1, agent_2, agent_3 = [ - self._build_agent( - hostname=f"agent_{i}", - cpu_cores=11, - ram_total=20e9, - ram_load=0.1, - ) - for i in range(1, 4) - ] - self.assertEqual( - [a.hostname for a in Agent.objects.all() if self._active_agent(a) is True], - ["agent_1", "agent_2", "agent_3"], - ) - # Add 9 pending tasks to the system - self._add_pending_tasks(9) - - # As agents are registered, they should retrieve 3 tasks each - tasks_agent_1 = agent_1.next_tasks() - self.assertEqual(len(tasks_agent_1), 3) - - tasks = agent_2.next_tasks() - self.assertEqual(len(tasks), 3) - - self._run_tasks([*tasks_agent_1, *tasks]) - - # Jump in the future. Agent 3 did never replied - future_now = timezone.now() + timedelta(seconds=2) - agent_1.cpu_load = 3.98 - agent_1.last_ping = future_now - agent_1.save() - agent_2.cpu_load = 3.99 - agent_2.last_ping = future_now - agent_2.save() - - with patch.object(model_tz, "now") as now_mock: - now_mock.return_value = future_now - self.assertEqual( - [ - a.hostname - for a in Agent.objects.all() - if self._active_agent(a) is True - ], - ["agent_1", "agent_2"], - ) - - # The 3 remaining tasks should be distributed among 2 agents with similar load - tasks_agent_1 = agent_1.next_tasks() - self.assertEqual(len(tasks_agent_1), 2) - - tasks = agent_2.next_tasks() - self.assertEqual(len(tasks), 1) - - self._run_tasks([*tasks_agent_1, *tasks]) - - self.assertEqual(Task.objects.filter(state=State.Pending).count(), 0) - - def test_filter_active_agents(self): - """ - Assert that the DB cost to attribute tasks does - not increase with tasks or agents number - """ - agent = self._build_agent() - tasks = self._add_pending_tasks(20) - - with self.assertNumQueries(4): - tasks = agent.next_tasks() - self.assertEqual(len(tasks), 1) - - # Build some inefficient agents - for i in range(1, 20): - self._build_agent( - hostname=f"agent_{i}", - cpu_cores=2, - ram_total=2e9, - ) - - self.assertEqual(Agent.objects.count(), 20) - with self.assertNumQueries(4): - tasks = agent.next_tasks() - self.assertEqual(len(tasks), 1) - - def test_gpu_assignation(self): - """ - Check a GPU enabled agent gets a GPU task - """ - # Agent normal has 20 free "slots" - # It should get all the tasks - agent_normal = self._build_agent( - hostname="agent_normal", cpu_cores=20, ram_total=20e9 - ) - - # But this one has a GPU: it will get GPU tasks in priority - agent_gpu = self._build_agent(hostname="agent_2", cpu_cores=10, ram_total=6e9) - agent_gpu.gpus.create(id=uuid.uuid4(), index=0, ram_total=8e9, name="Fake GPU") - - # Add 6 normal + 1 GPU pending tasks to the system - tasks = self._add_pending_tasks(7) - task_gpu = tasks[-1] - - # Require gpu on that task - task_gpu.command = "/usr/bin/nvidia-smi" - task_gpu.requires_gpu = True - task_gpu.save() - - # Normal agent should eat up most of the normal tasks - tasks = agent_normal.next_tasks() - self.assertEqual(len(tasks), 5) - self.assertFalse(any(t.requires_gpu for t in tasks)) - self.assertFalse(any(t.gpu for t in tasks)) - self._run_tasks(tasks) - - # GPU agent should then get the GPU task - tasks = agent_gpu.next_tasks() - self.assertEqual(len(tasks), 1) - self.assertTrue(all(t.requires_gpu for t in tasks)) - self.assertTrue(all(t.gpu for t in tasks)) - self._run_tasks(tasks) - - # Consume last normal task - tasks = agent_normal.next_tasks() - self.assertEqual(len(tasks), 1) - self._run_tasks(tasks) - - # No more tasks left - self.assertEqual(Task.objects.filter(state=State.Pending).count(), 0) - - def test_gpu_with_final_tasks_available(self): - """ - A GPU that is assigned to some final tasks should still be available - """ - gpu_agent = self._build_agent(hostname="matrix", cpu_cores=99, ram_total=9e12) - gpu = gpu_agent.gpus.create(id=uuid.uuid4(), index=0, ram_total=16e12, name="My cool GPU") - - # Create some final tasks that use this GPU - Task.objects.bulk_create( - Task( - depth=0, - run=0, - process=self.process, - requires_gpu=True, - slug=f"task{i}", - state=state, - gpu=gpu, - ) - for i, state in enumerate(FINAL_STATES) - ) - - # Create another task that needs a GPU and should be assigned to this agent - new_task = self.process.tasks.create( - run=0, - depth=0, - slug="new_task", - requires_gpu=True, - state=State.Unscheduled, - ) - - self.assertListEqual(gpu_agent.next_tasks(), [new_task]) - - new_task.refresh_from_db() - # The agent and GPU are assigned in Agent.next_actions, not next_tasks - self.assertIsNone(new_task.agent) - self.assertIsNone(new_task.gpu) - - def test_gpu_with_active_task_unavailable(self): - """ - A GPU that is assigned to any non-final task should not be available - """ - gpu_agent = self._build_agent(hostname="matrix", cpu_cores=99, ram_total=9e12) - gpu = gpu_agent.gpus.create(id=uuid.uuid4(), index=0, ram_total=16e12, name="My cool GPU") - - for state in ACTIVE_STATES: - self.process.tasks.all().delete() - with self.subTest(state=state): - # Create a task that is running on this agent's GPU - self.process.tasks.create( - run=0, - depth=0, - slug="current_task", - requires_gpu=True, - agent=gpu_agent, - gpu=gpu, - state=state, - ) - - # Create another task that needs a GPU - new_task = self.process.tasks.create( - run=0, - depth=0, - slug="new_task", - requires_gpu=True, - state=State.Unscheduled, - ) - - self.assertListEqual(gpu_agent.next_tasks(), []) - - new_task.refresh_from_db() - self.assertIsNone(new_task.agent) - self.assertIsNone(new_task.gpu) - - def test_multiple_farm_task_assignation(self): - """ - Distribute tasks depending on farm - """ - # Create one big agent on the test farm - test_agent = self._build_agent(cpu_cores=20, ram_total=64e9) - self._add_pending_tasks(3) - # and 3 small agents on the corn farm with a capacity for one task each - corn_farm = Farm.objects.create(name="Corn farm") - corn_agent_1, corn_agent_2 = [ - self._build_agent( - hostname=f"agent_{i}", - cpu_cores=2, - ram_total=2e9, - farm=corn_farm, - ) - for i in range(1, 3) - ] - - corn_process = corn_farm.processes.create( - mode=ProcessMode.Workers, - creator=self.superuser, - corpus=self.corpus, - ) - tasks = self._add_pending_tasks(3, process=corn_process) - - self.assertEqual(Task.objects.count(), 6) - self.assertEqual( - len([agent for agent in Agent.objects.all() if self._active_agent(agent)]), - 3, - ) - - # All except one task in the corn farm will be distributed - tasks = test_agent.next_tasks() - self.assertEqual(len(tasks), 3) - self.assertEqual( - set([task.process.farm_id for task in tasks]), {self.farm.id} - ) - - corn_tasks_1 = corn_agent_1.next_tasks() - self.assertEqual(len(corn_tasks_1), 1) - corn_tasks_2 = corn_agent_2.next_tasks() - self.assertEqual(len(corn_tasks_2), 1) - - self._run_tasks([*tasks, *corn_tasks_1, *corn_tasks_2]) - - # Update corn agents loads - Agent.objects.filter(farm=corn_farm).update(ram_load=0.95, cpu_load=1.9) - - # No agent can retrieve the last pending task - self.assertEqual(Task.objects.filter(state=State.Pending).count(), 1) - for agent in Agent.objects.all(): - self.assertEqual(len(agent.next_tasks()), 0) - - def test_next_tasks_ordering(self): - agent_1 = self._build_agent(hostname="agent_1", cpu_cores=11, ram_total=20e9) - self.assertEqual( - [a.hostname for a in Agent.objects.all() if self._active_agent(a) is True], - ["agent_1"], - ) - # Add 3 pending low priority tasks to the system - lp_tasks = self._add_pending_tasks(3, slug_ext="_lp", priority="1") - # Add 3 pending normal priority tasks to the system - np_tasks = self._add_pending_tasks(3, slug_ext="_np") - # Add 3 pending high priority tasks to the system - hp_tasks = self._add_pending_tasks(3, slug_ext="_hp", priority="100") - - # As agents are registered, they should retrieve 3 tasks each - tasks = agent_1.next_tasks() - self.assertEqual(len(tasks), 9) - # First three tasks should be the high priority ones - self.assertEqual(tasks[:3], hp_tasks) - # Second three tasks should be the normal priority ones - self.assertEqual(tasks[3:6], np_tasks) - # Last three tasks should be the low priority ones - self.assertEqual(tasks[6:], lp_tasks) - - def test_no_cpu_overload(self): - """A ponos agent should never run more tasks than its number of reported cores.""" - # Agent 1 has 4 free cores - agent_1 = self._build_agent(hostname="agent_1", cpu_cores=4, ram_total=128e9, cpu_load=0.0) - # Agent 2 only has 2 out of 64 cores available - agent_2 = self._build_agent(hostname="agent_2", cpu_cores=64, ram_total=128e9, cpu_load=61.9) - self.assertEqual( - [a.hostname for a in Agent.objects.all() if self._active_agent(a) is True], - ["agent_1", "agent_2"], - ) - - tasks = self._add_pending_tasks(3) - # Agent 1 has 4 CPU slots - self.assertEqual(len(agent_1.next_tasks()), 3) - # Agent 2 has a huge load - self.assertEqual(len(agent_2.next_tasks()), 0) - - # Assign those 3 tasks to agent 1, its real load is 10% - Task.objects.filter(id__in=[t.id for t in tasks]).update(agent=agent_1, state=State.Running) - agent_1.cpu_load = 0.4 - agent_1.save() - - self._add_pending_tasks(10, slug_ext="new") - # Agent 1 has a low load but 0 CPU slots according to the currently running tasks - self.assertEqual(len(agent_1.next_tasks()), 0) - # Agent 2 has a huge load but still 2 CPU slots - self.assertEqual(len(agent_2.next_tasks()), 2) diff --git a/arkindex/ponos/utils.py b/arkindex/ponos/utils.py index e20ad3fc11..eba004ebb5 100644 --- a/arkindex/ponos/utils.py +++ b/arkindex/ponos/utils.py @@ -1,3 +1,6 @@ +import os + +import magic from arkindex.ponos.models import Task @@ -8,3 +11,14 @@ def is_admin_or_ponos_task(request): def get_process_from_task_auth(request): if isinstance(request.auth, Task): return request.auth.process + + +def upload_artifact(task, path, artifacts_dir): + content_type = magic.from_file(path, mime=True) + size = os.path.getsize(path) + artifact = task.artifacts.create( + path=os.path.relpath(path, artifacts_dir), + content_type=content_type, + size=size, + ) + artifact.s3_object.upload_file(str(path), ExtraArgs={"ContentType": content_type}) diff --git a/arkindex/process/admin.py b/arkindex/process/admin.py index f02159a5c1..3349b5f270 100644 --- a/arkindex/process/admin.py +++ b/arkindex/process/admin.py @@ -13,7 +13,6 @@ from arkindex.process.models import ( WorkerVersion, ) from arkindex.project.admin import ArchivedListFilter -from arkindex.users.admin import GroupMembershipInline, UserMembershipInline class DataFileInline(admin.StackedInline): @@ -78,7 +77,7 @@ class RepositoryAdmin(admin.ModelAdmin): list_display = ("id", "url") fields = ("id", "url") readonly_fields = ("id", ) - inlines = [WorkerInline, UserMembershipInline, GroupMembershipInline] + inlines = [WorkerInline, ] class WorkerVersionInline(admin.StackedInline): @@ -106,7 +105,7 @@ class WorkerAdmin(admin.ModelAdmin): list_filter = (ArchivedListFilter, ) fields = ("id", "name", "slug", "type", "description", "repository", "public", "archived") readonly_fields = ("id", ) - inlines = [WorkerVersionInline, UserMembershipInline, GroupMembershipInline, WorkerConfigurationInline] + inlines = [WorkerVersionInline, WorkerConfigurationInline] def get_queryset(self, *args, **kwargs): return super().get_queryset(*args, **kwargs).select_related("repository", "type") diff --git a/arkindex/process/api.py b/arkindex/process/api.py index 13c1dfebb7..42cefcecb5 100644 --- a/arkindex/process/api.py +++ b/arkindex/process/api.py @@ -1076,7 +1076,7 @@ class CorpusWorkerVersionList(CorpusACLMixin, ListAPIView): @cached_property def corpus(self): - return get_object_or_404(self.readable_corpora, pk=self.kwargs["pk"]) + return get_object_or_404(Corpus.objects.readable(self.request.user), pk=self.kwargs["pk"]) def get_queryset(self): return ( @@ -1107,7 +1107,7 @@ class CorpusWorkerVersionList(CorpusACLMixin, ListAPIView): tags=["ml"], ) ) -class CorpusWorkerRunList(CorpusACLMixin, ListAPIView): +class CorpusWorkerRunList(ListAPIView): """ List worker runs used by any ML result in a given corpus. @@ -1126,7 +1126,7 @@ class CorpusWorkerRunList(CorpusACLMixin, ListAPIView): @cached_property def corpus(self): - return get_object_or_404(self.readable_corpora, pk=self.kwargs["pk"]) + return get_object_or_404(Corpus.objects.readable(self.request.user), pk=self.kwargs["pk"]) def get_queryset(self): return ( @@ -1145,7 +1145,7 @@ class CorpusWorkerRunList(CorpusACLMixin, ListAPIView): ), "process__tasks", ) - .filter(Q(process__corpus_id=self.corpus.id) | Q(process__creator_id=self.user.id, process__mode=ProcessMode.Local), has_results=True) + .filter(Q(process__corpus_id=self.corpus.id) | Q(process__creator_id=self.request.user.id, process__mode=ProcessMode.Local), has_results=True) .annotate(process_element_count=Count("process__elements")) .order_by("summary") ) diff --git a/arkindex/process/builder.py b/arkindex/process/builder.py index 33e7d7f649..dd3445337f 100644 --- a/arkindex/process/builder.py +++ b/arkindex/process/builder.py @@ -213,8 +213,6 @@ class ProcessBuilder(object): """ from arkindex.process.models import ActivityState, ProcessMode - if not self.process.farm_id: - raise ValidationError("Process must have a farm ID to be built") if self.run > 0 and not self.process.is_final: raise ValidationError("Cannot run a process that is not in a final state.") if self.process.mode in (ProcessMode.Local, ProcessMode.Template): @@ -393,3 +391,4 @@ class ProcessBuilder(object): for child_slug, parent_slugs in self.tasks_parents.items() for parent_slug in parent_slugs ) + return tasks diff --git a/arkindex/process/models.py b/arkindex/process/models.py index ac26165f47..714e382c91 100644 --- a/arkindex/process/models.py +++ b/arkindex/process/models.py @@ -1,5 +1,6 @@ import urllib.parse import uuid +from functools import partial from typing import Optional from django.conf import settings @@ -390,11 +391,16 @@ class Process(IndexableModel): """ Build and start a new run for this process. """ + from arkindex.project.triggers import schedule_tasks + process_builder = ProcessBuilder(self) process_builder.validate() process_builder.build() # Save all tasks and their relations process_builder.save() + if settings.PONOS_RQ_EXECUTION: + # Trigger tasks execution in RQ after the current transaction so tasks have been created + transaction.on_commit(partial(schedule_tasks, process=self, run=process_builder.run)) self.started = timezone.now() self.finished = None diff --git a/arkindex/process/serializers/imports.py b/arkindex/process/serializers/imports.py index b5a793d49d..a0688da6ca 100644 --- a/arkindex/process/serializers/imports.py +++ b/arkindex/process/serializers/imports.py @@ -2,12 +2,13 @@ from collections import defaultdict from textwrap import dedent from django.conf import settings +from django.utils.module_loading import import_string from rest_framework import serializers from rest_framework.exceptions import PermissionDenied, ValidationError from arkindex.documents.models import Corpus, Element, ElementType, MLClass from arkindex.ponos.models import Farm, State -from arkindex.ponos.serializers import FarmSerializer, TaskLightSerializer +from arkindex.ponos.serializers import TaskLightSerializer from arkindex.process.models import ( ActivityState, DataFile, @@ -18,7 +19,6 @@ from arkindex.process.models import ( WorkerVersionState, ) from arkindex.process.serializers.git import RevisionSerializer -from arkindex.process.utils import get_default_farm from arkindex.project.mixins import ProcessACLMixin from arkindex.project.serializer_fields import EnumField, LinearRingField from arkindex.project.validators import MaxValueValidator @@ -26,6 +26,9 @@ from arkindex.training.models import ModelVersionState from arkindex.users.models import Role from arkindex.users.utils import get_max_level +ProcessFarmField = import_string(getattr(settings, "PROCESS_FARM_FIELD", None) or "arkindex.project.serializer_fields.NullField") +get_default_farm = import_string(getattr(settings, "GET_DEFAULT_FARM", None) or "arkindex.process.utils.get_default_farm") + class ProcessLightSerializer(serializers.ModelSerializer): """ @@ -138,7 +141,7 @@ class ProcessSerializer(ProcessLightSerializer): class ProcessDetailsSerializer(ProcessSerializer): - farm = FarmSerializer(read_only=True) + farm = ProcessFarmField(read_only=True) tasks = TaskLightSerializer(many=True, read_only=True) class Meta(ProcessSerializer.Meta): @@ -322,7 +325,7 @@ class FilesProcessSerializer(serializers.ModelSerializer): if farm is None: farm = get_default_farm() - if not farm.is_available(self.context["request"].user): + if farm and not farm.is_available(self.context["request"].user): raise ValidationError(["You do not have access to this farm."]) return farm @@ -406,7 +409,7 @@ class StartProcessSerializer(serializers.Serializer): if farm is None: farm = get_default_farm() - if not farm.is_available(self.context["request"].user): + if farm and not farm.is_available(self.context["request"].user): raise ValidationError(["You do not have access to this farm."]) return farm diff --git a/arkindex/process/serializers/ingest.py b/arkindex/process/serializers/ingest.py index 77da4b8384..c888bb7ea5 100644 --- a/arkindex/process/serializers/ingest.py +++ b/arkindex/process/serializers/ingest.py @@ -86,7 +86,7 @@ class S3ImportSerializer(serializers.ModelSerializer): if farm is None: farm = get_default_farm() - if not farm.is_available(self.context["request"].user): + if farm and not farm.is_available(self.context["request"].user): raise serializers.ValidationError(["You do not have access to this farm."]) return farm diff --git a/arkindex/process/tests/test_corpus_worker_runs.py b/arkindex/process/tests/test_corpus_worker_runs.py index 92aebf153a..2e62a89f37 100644 --- a/arkindex/process/tests/test_corpus_worker_runs.py +++ b/arkindex/process/tests/test_corpus_worker_runs.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -80,16 +82,19 @@ class TestCorpusWorkerRuns(FixtureAPITestCase): cls.user_local_run.has_results = True cls.user_local_run.save() - def test_list_requires_read_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_requires_read_access(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.get(reverse("api:corpus-runs", kwargs={"pk": self.private_corpus.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) def test_list(self): self.private_corpus.memberships.create(user=self.user, level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(7): response = self.client.get(reverse("api:corpus-runs", kwargs={"pk": self.private_corpus.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["results"], [ diff --git a/arkindex/process/tests/test_create_process.py b/arkindex/process/tests/test_create_process.py index 008c963eaa..e15b55f994 100644 --- a/arkindex/process/tests/test_create_process.py +++ b/arkindex/process/tests/test_create_process.py @@ -181,7 +181,8 @@ class TestCreateProcess(FixtureAPITestCase): {"corpus": ["This field is required."]} ) - def test_create_process_requires_corpus_admin(self): + @patch("arkindex.process.serializers.imports.get_max_level", return_value=Role.Contributor.value) + def test_create_process_requires_corpus_admin(self, get_max_level_mock): """ Only an admin of the target corpus can create a workers process """ @@ -198,7 +199,11 @@ class TestCreateProcess(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"corpus": ["You do not have an admin access to this corpus."]}) - def test_create_process_non_readable_corpus(self): + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + + @patch("arkindex.process.serializers.imports.get_max_level", return_value=None) + def test_create_process_non_readable_corpus(self, get_max_level_mock): self.client.force_login(self.user) response = self.client.post( reverse("api:corpus-process"), @@ -211,6 +216,9 @@ class TestCreateProcess(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"corpus": ["Corpus with this ID does not exist."]}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.private_corpus)) + def test_create_process_name_filter(self): self.client.force_login(self.user) response = self.client.post( @@ -337,7 +345,7 @@ class TestCreateProcess(FixtureAPITestCase): def test_ml_class_filter(self): self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.post( reverse("api:corpus-process"), { @@ -376,7 +384,7 @@ class TestCreateProcess(FixtureAPITestCase): """ self.client.force_login(self.user) element = self.corpus.elements.create(type=self.pages.first().type, name="Kill me please") - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.post( reverse("api:corpus-process"), { @@ -577,7 +585,7 @@ class TestCreateProcess(FixtureAPITestCase): self.assertFalse(self.corpus.worker_versions.exists()) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process_2.id)}), {"worker_activity": True}, @@ -668,7 +676,7 @@ class TestCreateProcess(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process_2.id)}), {"use_cache": True}, @@ -716,7 +724,7 @@ class TestCreateProcess(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process_2.id)}), {"use_gpu": True}, @@ -787,7 +795,7 @@ class TestCreateProcess(FixtureAPITestCase): process.use_gpu = True process.save() self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}), {"use_gpu": "true"} @@ -811,7 +819,7 @@ class TestCreateProcess(FixtureAPITestCase): process.use_gpu = True process.save() self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}), {"use_gpu": "true"} @@ -831,7 +839,7 @@ class TestCreateProcess(FixtureAPITestCase): ProcessDataset.objects.create(process=process, dataset=dataset, sets=dataset.sets) process.versions.set([self.version_2, self.version_3]) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -844,7 +852,7 @@ class TestCreateProcess(FixtureAPITestCase): process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) process.versions.set([self.version_2, self.version_3]) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -861,7 +869,7 @@ class TestCreateProcess(FixtureAPITestCase): ProcessDataset.objects.create(process=process, dataset=dataset, sets=dataset.sets) process.versions.add(self.version_1) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -877,7 +885,7 @@ class TestCreateProcess(FixtureAPITestCase): process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) process.versions.add(self.version_1) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -899,7 +907,7 @@ class TestCreateProcess(FixtureAPITestCase): process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) process.versions.add(custom_version) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -936,7 +944,7 @@ class TestCreateProcess(FixtureAPITestCase): } if mode: data["mode"] = mode - with (self.subTest(mode=mode), self.assertNumQueries(11)): + with (self.subTest(mode=mode), self.assertNumQueries(8)): response = self.client.post( reverse("api:corpus-process"), data, @@ -968,7 +976,7 @@ class TestCreateProcess(FixtureAPITestCase): def test_create_process_dataset_mode_no_selection(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:corpus-process"), { @@ -989,7 +997,7 @@ class TestCreateProcess(FixtureAPITestCase): self.client.force_login(self.user) cases = [item.value for item in ProcessMode if item.value not in ["workers", "dataset"]] for mode in cases: - with (self.subTest(mode=mode), self.assertNumQueries(9)): + with (self.subTest(mode=mode), self.assertNumQueries(6)): response = self.client.post( reverse("api:corpus-process"), { diff --git a/arkindex/process/tests/test_create_s3_import.py b/arkindex/process/tests/test_create_s3_import.py index 454f3282c9..5d95c18a68 100644 --- a/arkindex/process/tests/test_create_s3_import.py +++ b/arkindex/process/tests/test_create_s3_import.py @@ -1,3 +1,6 @@ +from unittest import expectedFailure +from unittest.mock import call, patch + from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -18,7 +21,6 @@ class TestCreateS3Import(FixtureTestCase): cls.import_worker_version = WorkerVersion.objects.get(worker__slug="file_import") cls.default_farm = Farm.objects.create(name="Crypto farm") cls.default_farm.memberships.create(user=cls.user, level=Role.Guest.value) - cls.other_farm = Farm.objects.get(name="Wheat farm") def test_requires_login(self): with self.assertNumQueries(0): @@ -59,7 +61,9 @@ class TestCreateS3Import(FixtureTestCase): }) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_invalid(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_invalid(self, filter_rights_mock): + filter_rights_mock.return_value = Corpus.objects.filter(id=self.corpus.id) self.user.user_scopes.create(scope=Scope.S3Ingest) self.client.force_login(self.user) private_corpus = Corpus.objects.create(name="nope") @@ -111,10 +115,13 @@ class TestCreateS3Import(FixtureTestCase): ), ] for request, expected in cases: + filter_rights_mock.reset_mock() with self.subTest(request=request): response = self.client.post(reverse("api:s3-import-create"), request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), expected) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Admin.value)) @override_settings( PONOS_DEFAULT_ENV={}, @@ -132,7 +139,7 @@ class TestCreateS3Import(FixtureTestCase): ImageServer.objects.create(id=999, display_name="Ingest image server", url="https://dev.null.teklia.com") element = self.corpus.elements.get(name="Volume 1") - with self.assertNumQueries(27), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(22), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.post(reverse("api:s3-import-create"), { "corpus_id": str(self.corpus.id), "element_id": str(element.id), @@ -140,7 +147,6 @@ class TestCreateS3Import(FixtureTestCase): "element_type": "page", "bucket_name": "blah", "prefix": "a/b/c", - "farm_id": str(self.other_farm.id), }) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) data = response.json() @@ -196,7 +202,7 @@ class TestCreateS3Import(FixtureTestCase): self.corpus.types.create(slug="folder", display_name="Folder", folder=True) ImageServer.objects.create(id=999, display_name="Ingest image server", url="https://dev.null.teklia.com") - with self.assertNumQueries(26), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(21), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.post(reverse("api:s3-import-create"), { "corpus_id": str(self.corpus.id), "bucket_name": "blah", @@ -208,7 +214,7 @@ class TestCreateS3Import(FixtureTestCase): self.assertDictEqual(data, {"id": str(process.id)}) self.assertEqual(process.mode, ProcessMode.S3) self.assertEqual(process.creator, self.user) - self.assertEqual(process.farm, self.default_farm) + self.assertEqual(process.farm, None) self.assertEqual(process.corpus_id, self.corpus.id) self.assertIsNone(process.element_id) self.assertEqual(process.folder_type, self.corpus.types.get(slug="folder")) @@ -239,19 +245,19 @@ class TestCreateS3Import(FixtureTestCase): "INGEST_S3_SECRET_KEY": "its-secret-i-wont-tell-you", }) - def test_farm_guest(self): + @expectedFailure + @override_settings(INGEST_IMAGESERVER_ID=999) + @patch("arkindex.users.utils.get_max_level", return_value=None) + def test_farm_guest(self, get_max_level_mock): self.user.user_scopes.create(scope=Scope.S3Ingest) self.client.force_login(self.user) self.corpus.types.create(slug="folder", display_name="Folder", folder=True) ImageServer.objects.create(id=999, display_name="Ingest image server", url="https://dev.null.teklia.com") - self.other_farm.memberships.filter(user=self.user).delete() - - with self.assertNumQueries(8), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(5), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.post(reverse("api:s3-import-create"), { "corpus_id": str(self.corpus.id), "bucket_name": "blah", - "farm_id": str(self.other_farm.id), }) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -260,7 +266,12 @@ class TestCreateS3Import(FixtureTestCase): }) self.assertFalse(Process.objects.filter(mode=ProcessMode.S3).exists()) - def test_default_farm_guest(self): + self.assertEqual(get_max_level_mock.call_count, 1) + + @expectedFailure + @override_settings(INGEST_IMAGESERVER_ID=999) + @patch("arkindex.users.utils.get_max_level", return_value=None) + def test_default_farm_guest(self, get_max_level_mock): self.user.user_scopes.create(scope=Scope.S3Ingest) self.client.force_login(self.user) self.corpus.types.create(slug="folder", display_name="Folder", folder=True) @@ -268,7 +279,7 @@ class TestCreateS3Import(FixtureTestCase): self.default_farm.memberships.filter(user=self.user).delete() - with self.assertNumQueries(8), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(5), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.post(reverse("api:s3-import-create"), { "corpus_id": str(self.corpus.id), "bucket_name": "blah", @@ -279,3 +290,6 @@ class TestCreateS3Import(FixtureTestCase): "farm_id": ["You do not have access to this farm."], }) self.assertFalse(Process.objects.filter(mode=ProcessMode.S3).exists()) + + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.default_farm)) diff --git a/arkindex/process/tests/test_datafile_api.py b/arkindex/process/tests/test_datafile_api.py index 2d673ad00c..97b722c588 100644 --- a/arkindex/process/tests/test_datafile_api.py +++ b/arkindex/process/tests/test_datafile_api.py @@ -39,7 +39,8 @@ class TestDataFileApi(FixtureAPITestCase): response = self.client.post(reverse("api:file-create"), request) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_df_corpus_acl(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_df_corpus_acl(self, filter_rights_mock): self.client.force_login(self.user) private_corpus = Corpus.objects.create(name="private") request = self.build_file_create_request(corpus=str(private_corpus.id)) diff --git a/arkindex/process/tests/test_process_datasets.py b/arkindex/process/tests/test_process_datasets.py index 4efcd2af60..af98d7b253 100644 --- a/arkindex/process/tests/test_process_datasets.py +++ b/arkindex/process/tests/test_process_datasets.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import call, patch from django.urls import reverse from rest_framework import status @@ -69,17 +69,21 @@ class TestProcessDatasets(FixtureAPITestCase): response = self.client.get(reverse("api:process-datasets", kwargs={"pk": str(uuid.uuid4())})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_list_process_access_level(self): + @patch("arkindex.project.mixins.get_max_level", return_value=None) + def test_list_process_access_level(self, get_max_level_mock): self.private_corpus.memberships.filter(user=self.test_user).delete() self.client.force_login(self.test_user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:process-datasets", kwargs={"pk": self.dataset_process.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have guest access to this process."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.test_user, self.private_corpus)) + def test_list(self): self.client.force_login(self.test_user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-datasets", kwargs={"pk": self.dataset_process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["results"], [ @@ -139,22 +143,27 @@ class TestProcessDatasets(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_access_level(self): - cases = [None, Role.Guest, Role.Contributor] + @patch("arkindex.project.mixins.get_max_level") + def test_create_access_level(self, get_max_level_mock): + cases = [None, Role.Guest.value, Role.Contributor.value] for level in cases: with self.subTest(level=level): - self.private_corpus.memberships.filter(user=self.test_user).delete() - if level: - self.private_corpus.memberships.create(user=self.test_user, level=level.value) + get_max_level_mock.reset_mock() + get_max_level_mock.return_value = level self.client.force_login(self.test_user) - with self.assertNumQueries(5): + + with self.assertNumQueries(3): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), data={"sets": self.dataset2.sets} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.test_user, self.private_corpus)) + def test_create_process_mode(self): cases = set(ProcessMode) - {ProcessMode.Dataset, ProcessMode.Local, ProcessMode.Repository} for mode in cases: @@ -163,7 +172,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.dataset_process.save() self.client.force_login(self.test_user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), data={"sets": self.dataset2.sets} @@ -183,17 +192,6 @@ class TestProcessDatasets(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) - def test_create_process_mode_repository(self): - self.client.force_login(self.user) - process = Process.objects.create(creator=self.user, mode=ProcessMode.Repository, revision=self.rev) - with self.assertNumQueries(6): - response = self.client.post( - reverse("api:process-dataset", kwargs={"process": process.id, "dataset": self.dataset2.id}), - data={"sets": self.dataset2.sets} - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) - def test_create_wrong_process_uuid(self): self.client.force_login(self.test_user) wrong_id = uuid.uuid4() @@ -208,7 +206,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_create_wrong_dataset_uuid(self): self.client.force_login(self.test_user) wrong_id = uuid.uuid4() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": wrong_id}), data={"sets": ["test"]} @@ -216,23 +214,29 @@ class TestProcessDatasets(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"dataset": [f'Invalid pk "{str(wrong_id)}" - object does not exist.']}) - def test_create_dataset_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_create_dataset_access(self, filter_rights_mock): new_corpus = Corpus.objects.create(name="NERV") new_dataset = new_corpus.datasets.create(name="Eva series", description="We created the Evas from Adam", creator=self.user) self.client.force_login(self.test_user) - with self.assertNumQueries(7): + + with self.assertNumQueries(3): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": new_dataset.id}), data={"sets": new_dataset.sets} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"dataset": [f'Invalid pk "{str(new_dataset.id)}" - object does not exist.']}) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.test_user, Corpus, Role.Guest.value)) + def test_create_unique(self): self.client.force_login(self.test_user) self.assertTrue(self.dataset_process.datasets.filter(id=self.dataset1.id).exists()) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": self.dataset1.sets} @@ -245,7 +249,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.client.force_login(self.test_user) self.dataset_process.tasks.create(run=0, depth=0, slug="makrout") - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), data={"sets": self.dataset2.sets} @@ -258,7 +262,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.client.force_login(self.test_user) self.assertEqual(ProcessDataset.objects.count(), 3) self.assertFalse(ProcessDataset.objects.filter(process=self.dataset_process.id, dataset=self.dataset2.id).exists()) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse( "api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id} @@ -295,7 +299,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.client.force_login(self.test_user) self.assertEqual(ProcessDataset.objects.count(), 3) self.assertFalse(ProcessDataset.objects.filter(process=self.dataset_process.id, dataset=self.dataset2.id).exists()) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), data={"sets": ["validation", "test"]} @@ -331,7 +335,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.client.force_login(self.test_user) self.assertEqual(ProcessDataset.objects.count(), 3) self.assertFalse(ProcessDataset.objects.filter(process=self.dataset_process.id, dataset=self.dataset2.id).exists()) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), data={"sets": ["Unit-01"]} @@ -368,13 +372,12 @@ class TestProcessDatasets(FixtureAPITestCase): if level: self.private_corpus.memberships.create(user=self.test_user, level=level.value) self.client.force_login(self.test_user) - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_update_process_does_not_exist(self): self.client.force_login(self.test_user) @@ -405,7 +408,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_update(self): self.client.force_login(self.test_user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} @@ -431,7 +434,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_update_wrong_sets(self): self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["Unit-01", "Unit-02"]} @@ -441,7 +444,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_update_unique_sets(self): self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test", "test"]} @@ -457,7 +460,7 @@ class TestProcessDatasets(FixtureAPITestCase): expiry=datetime(1970, 1, 1, tzinfo=timezone.utc), ) self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} @@ -470,7 +473,7 @@ class TestProcessDatasets(FixtureAPITestCase): Non "sets" fields in the update request are ignored """ self.client.force_login(self.test_user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"process": str(self.dataset_process_2.id), "dataset": str(self.dataset2.id), "sets": ["test"]} @@ -522,13 +525,12 @@ class TestProcessDatasets(FixtureAPITestCase): if level: self.private_corpus.memberships.create(user=self.test_user, level=level.value) self.client.force_login(self.test_user) - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_partial_update_process_does_not_exist(self): self.client.force_login(self.test_user) @@ -559,7 +561,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_partial_update(self): self.client.force_login(self.test_user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} @@ -585,7 +587,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_partial_update_wrong_sets(self): self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["Unit-01", "Unit-02"]} @@ -595,7 +597,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_partial_update_unique_sets(self): self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test", "test"]} @@ -611,7 +613,7 @@ class TestProcessDatasets(FixtureAPITestCase): expiry=datetime(1970, 1, 1, tzinfo=timezone.utc), ) self.client.force_login(self.test_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"sets": ["test"]} @@ -624,7 +626,7 @@ class TestProcessDatasets(FixtureAPITestCase): Non "sets" fields in the partial update request are ignored """ self.client.force_login(self.test_user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), data={"process": str(self.dataset_process_2.id), "dataset": str(self.dataset2.id), "sets": ["test"]} @@ -670,7 +672,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_destroy_dataset_does_not_exist(self): self.client.force_login(self.test_user) wrong_id = uuid.uuid4() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": wrong_id}) ) @@ -680,29 +682,33 @@ class TestProcessDatasets(FixtureAPITestCase): def test_destroy_not_found(self): self.assertFalse(self.dataset_process.datasets.filter(id=self.dataset2.id).exists()) self.client.force_login(self.test_user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_destroy_process_access_level(self): - self.private_corpus.memberships.filter(user=self.test_user).delete() + @patch("arkindex.project.mixins.get_max_level", return_value=None) + def test_destroy_process_access_level(self, get_max_level_mock): self.client.force_login(self.test_user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.private_dataset.id}) ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have admin access to this process."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.test_user, self.private_corpus)) + def test_destroy_no_dataset_access_requirement(self): new_corpus = Corpus.objects.create(name="NERV") new_dataset = new_corpus.datasets.create(name="Eva series", description="We created the Evas from Adam", creator=self.user) ProcessDataset.objects.create(process=self.dataset_process, dataset=new_dataset, sets=new_dataset.sets) self.assertTrue(ProcessDataset.objects.filter(process=self.dataset_process, dataset=new_dataset).exists()) self.client.force_login(self.test_user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": new_dataset.id}), ) @@ -717,7 +723,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.dataset_process.save() self.client.force_login(self.test_user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset2.id}), ) @@ -735,21 +741,11 @@ class TestProcessDatasets(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) - def test_destroy_process_mode_repository(self): - self.client.force_login(self.user) - process = Process.objects.create(creator=self.user, mode=ProcessMode.Repository, revision=self.rev) - with self.assertNumQueries(6): - response = self.client.delete( - reverse("api:process-dataset", kwargs={"process": process.id, "dataset": self.dataset2.id}), - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have admin access to this process."}) - def test_destroy_started(self): self.client.force_login(self.test_user) self.dataset_process.tasks.create(run=0, depth=0, slug="makrout") - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), ) @@ -759,7 +755,7 @@ class TestProcessDatasets(FixtureAPITestCase): def test_destroy(self): self.client.force_login(self.test_user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), ) @@ -774,7 +770,7 @@ class TestProcessDatasets(FixtureAPITestCase): self.process_dataset_1.sets = ["test"] self.process_dataset_1.save() self.client.force_login(self.test_user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:process-dataset", kwargs={"process": self.dataset_process.id, "dataset": self.dataset1.id}), ) diff --git a/arkindex/process/tests/test_process_elements.py b/arkindex/process/tests/test_process_elements.py index 89d0e44de1..e437584d2d 100644 --- a/arkindex/process/tests/test_process_elements.py +++ b/arkindex/process/tests/test_process_elements.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import call, patch from django.urls import reverse from rest_framework import status @@ -7,7 +8,7 @@ from arkindex.documents.models import Corpus, Element, ElementPath from arkindex.images.models import Image from arkindex.process.models import Process, ProcessMode from arkindex.project.tests import FixtureAPITestCase -from arkindex.users.models import User +from arkindex.users.models import Role class TestProcessElements(FixtureAPITestCase): @@ -158,15 +159,19 @@ class TestProcessElements(FixtureAPITestCase): response = self.client.get(reverse("api:process-elements-list", kwargs={"pk": str(uuid.uuid4())})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_no_access(self): - self.process.corpus = Corpus.objects.create(name="private") - self.corpus.creator = User.objects.create_user("John Doe") - self.process.save() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_no_access(self, has_access_mock): self.client.force_login(self.user) - response = self.client.get(reverse("api:process-elements-list", kwargs={"pk": self.process.id})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + with self.assertNumQueries(4): + response = self.client.get(reverse("api:process-elements-list", kwargs={"pk": self.process.id})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have an admin access to the corpus of this process."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Admin.value, skip_public=False)) + def test_filter_elements_wrong_corpus(self): """ Selected elements must be part of the same corpus as the process diff --git a/arkindex/process/tests/test_processes.py b/arkindex/process/tests/test_processes.py index d8bdb5cef8..38d06536e2 100644 --- a/arkindex/process/tests/test_processes.py +++ b/arkindex/process/tests/test_processes.py @@ -1,4 +1,5 @@ import uuid +from unittest import expectedFailure from unittest.mock import call, patch from django.conf import settings @@ -36,10 +37,6 @@ class TestProcesses(FixtureAPITestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - # The default farm will be the first one in alphabetical order - cls.default_farm = Farm.objects.create(name="Corn farm") - cls.default_farm.memberships.create(user=cls.user, level=Role.Guest.value) - cls.other_farm = Farm.objects.get(name="Wheat farm") cls.repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") cls.rev = cls.repo.revisions.get() cls.dataset1, cls.dataset2 = Dataset.objects.filter(corpus=cls.corpus).order_by("name") @@ -86,7 +83,6 @@ class TestProcesses(FixtureAPITestCase): mode=ProcessMode.Files, creator=cls.user, corpus=cls.private_corpus, - farm=cls.default_farm, ) cls.private_ml_class = cls.private_corpus.ml_classes.create(name="beignet") @@ -96,13 +92,11 @@ class TestProcesses(FixtureAPITestCase): mode=ProcessMode.Repository, creator=cls.user2, revision=cls.rev, - farm=cls.default_farm, ) # Admin access cls.elts_process = cls.corpus.processes.create( mode=ProcessMode.Workers, creator=cls.user2, - farm=cls.default_farm, ) cls.processes = Process.objects.filter( id__in=[cls.user_img_process.id, cls.repository_process.id, cls.elts_process.id] @@ -137,7 +131,7 @@ class TestProcesses(FixtureAPITestCase): task.save() self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list")) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -219,7 +213,7 @@ class TestProcesses(FixtureAPITestCase): self.user_img_process.activity_state = ActivityState.Ready self.user_img_process.save() - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"started": "true"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -254,7 +248,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.run() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:process-list"), {"started": "false"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -321,7 +315,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) assert Process.objects.exclude(mode=ProcessMode.Files).exists() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:process-list"), {"mode": ProcessMode.Files.value, "started": False}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -335,7 +329,7 @@ class TestProcesses(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:process-list"), {"mode": ProcessMode.Local.value}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -377,7 +371,7 @@ class TestProcesses(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"created": "true"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -424,7 +418,7 @@ class TestProcesses(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"created": "false"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -449,7 +443,7 @@ class TestProcesses(FixtureAPITestCase): mode=ProcessMode.Files, ) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:process-list"), {"id": process_id[:10], "started": False}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -472,7 +466,7 @@ class TestProcesses(FixtureAPITestCase): name="Numero Duo" ) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:process-list"), {"name": "Numb", "started": False}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -510,7 +504,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(stopped_process.state, State.Stopped) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"state": "completed"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -539,7 +533,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(stopped_process_2.state, State.Stopped) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"state": "stopped"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -571,7 +565,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(self.elts_process.state, State.Unscheduled) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:process-list"), {"state": "unscheduled", "started": True}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -682,7 +676,7 @@ class TestProcesses(FixtureAPITestCase): self.user_img_process.run() task = self.user_img_process.tasks.get() - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get(reverse("api:process-details", kwargs={"pk": self.user_img_process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -692,10 +686,7 @@ class TestProcesses(FixtureAPITestCase): "element": None, "element_name_contains": None, "element_type": None, - "farm": { - "id": str(self.default_farm.id), - "name": "Corn farm", - }, + "farm": None, "files": [], "folder_type": None, "id": str(self.user_img_process.id), @@ -763,18 +754,10 @@ class TestProcesses(FixtureAPITestCase): {"__all__": ["Please wait activities to be initialized before deleting this process"]} ) - def test_delete_repository_import_no_permission(self): - """ - Deletion of a repository import requires to be admin on the repository - """ - self.client.force_login(self.user) - response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.repository_process.id})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), {"detail": "You do not have a sufficient access level to this process."}) - - def test_delete_corpus_import_no_permission(self): + @expectedFailure + def test_delete_process_no_permission(self): """ - A user cannot delete a process linked to a corpus he has no admin access to + A user cannot delete a process linked to a corpus they have no admin access to """ self.client.force_login(self.user) self.assertFalse(self.user_img_process.corpus.memberships.filter(user=self.user).exists()) @@ -820,7 +803,7 @@ class TestProcesses(FixtureAPITestCase): If no activities exists for this process, it is deleted directly. """ self.client.force_login(self.user) - with self.assertNumQueries(22): + with self.assertNumQueries(19): response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) with self.assertRaises(Process.DoesNotExist): @@ -847,7 +830,7 @@ class TestProcesses(FixtureAPITestCase): element=self.corpus.elements.get(name="Volume 1"), worker_version=WorkerVersion.objects.get(worker__slug="reco"), ) - with self.assertNumQueries(14): + with self.assertNumQueries(11): response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(delay_mock.call_count, 1) @@ -906,7 +889,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) self.elts_process.run() self.elts_process.tasks.update(state=State.Running) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"element_name_contains": "something"}, @@ -923,7 +906,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.run() self.elts_process.tasks.update(state=State.Running) self.assertEqual(self.elts_process.name, None) - with self.assertNumQueries(14): + with self.assertNumQueries(11): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"name": "newName"}, @@ -942,7 +925,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( mode=ProcessMode.Files, creator=self.user, - farm=self.default_farm, ) with self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): process.run() @@ -952,7 +934,7 @@ class TestProcesses(FixtureAPITestCase): # Take any element with an image: this will cause extra queries to retrieve the Image and ImageServer element = self.corpus.elements.exclude(image_id=None).first() - with self.assertNumQueries(15), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(12), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.patch( reverse("api:process-details", kwargs={"pk": process.id}), {"element_id": str(element.id)}, @@ -963,6 +945,7 @@ class TestProcesses(FixtureAPITestCase): process.refresh_from_db() self.assertEqual(process.element, element) + @expectedFailure def test_partial_update_no_permission(self): """ A user cannot update a process linked to a corpus he has no admin access to @@ -973,6 +956,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have a sufficient access level to this process."}) + @expectedFailure def test_partial_update_repository_requires_admin(self): """ Edition of a repository import requires to be admin on the repository @@ -998,7 +982,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.refresh_from_db() self.assertEqual(self.elts_process.state, state) - with self.assertNumQueries(16): + with self.assertNumQueries(13): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"state": "stopping"}, @@ -1018,7 +1002,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.run() self.assertEqual(self.elts_process.state, State.Unscheduled) - with self.assertNumQueries(17): + with self.assertNumQueries(14): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"state": "stopping"}, @@ -1042,7 +1026,7 @@ class TestProcesses(FixtureAPITestCase): for state in set(State) - {State.Stopping}: with self.subTest(state=state): - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"state": state.value}, @@ -1070,7 +1054,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.refresh_from_db() self.assertEqual(self.elts_process.state, state) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), {"state": "stopping"}, @@ -1159,7 +1143,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.run() self.assertEqual(self.elts_process.state, State.Unscheduled) - with self.assertNumQueries(18): + with self.assertNumQueries(15): response = self.client.put( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), { @@ -1190,7 +1174,7 @@ class TestProcesses(FixtureAPITestCase): for state in set(State) - {State.Stopping}: with self.subTest(state=state): - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.put( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), { @@ -1225,7 +1209,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.refresh_from_db() self.assertEqual(self.elts_process.state, state) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.put( reverse("api:process-details", kwargs={"pk": self.elts_process.id}), { @@ -1259,7 +1243,7 @@ class TestProcesses(FixtureAPITestCase): self.assertIsNone(process.ml_class) self.assertFalse(process.load_children) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.put( reverse("api:process-details", kwargs={"pk": process.id}), { @@ -1287,9 +1271,9 @@ class TestProcesses(FixtureAPITestCase): non_existent_id = uuid.uuid4() cases = [ - ("je says pas", "“je says pas†is not a valid UUID.", 9), - (non_existent_id, f'Invalid pk "{non_existent_id}" - object does not exist.', 10), - (self.private_ml_class.id, f'Invalid pk "{self.private_ml_class.id}" - object does not exist.', 10), + ("je says pas", "“je says pas†is not a valid UUID.", 6), + (non_existent_id, f'Invalid pk "{non_existent_id}" - object does not exist.', 7), + (self.private_ml_class.id, f'Invalid pk "{self.private_ml_class.id}" - object does not exist.', 7), ] for ml_class_id, message, queries in cases: with self.subTest(ml_class_id=ml_class_id), self.assertNumQueries(queries): @@ -1318,7 +1302,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) process = Process.objects.create(mode=ProcessMode.Workers, corpus=self.corpus, creator=self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:process-details", kwargs={"pk": process.id}), { @@ -1352,6 +1336,7 @@ class TestProcesses(FixtureAPITestCase): "name": "newName", "element_name_contains": "AAA", "element_type": "page", + "farm": None, "folder_type": None, "ml_class_id": None, "load_children": True, @@ -1366,7 +1351,6 @@ class TestProcesses(FixtureAPITestCase): "revision": None, "state": "unscheduled", "template_id": None, - "farm": None, "tasks": [], "created": process.created.isoformat().replace("+00:00", "Z"), "updated": process.updated.isoformat().replace("+00:00", "Z"), @@ -1442,6 +1426,7 @@ class TestProcesses(FixtureAPITestCase): process.refresh_from_db() self.assertEqual(process.name, "newName") + @expectedFailure def test_partial_update_corpus_no_write_right(self): self.client.force_login(self.user) self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) @@ -1461,7 +1446,7 @@ class TestProcesses(FixtureAPITestCase): self.assertIsNone(process.ml_class) self.assertFalse(process.load_children) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.patch( reverse("api:process-details", kwargs={"pk": process.id}), { @@ -1530,9 +1515,9 @@ class TestProcesses(FixtureAPITestCase): non_existent_id = uuid.uuid4() cases = [ - ("je says pas", "“je says pas†is not a valid UUID.", 8), - (non_existent_id, f'Invalid pk "{non_existent_id}" - object does not exist.', 9), - (self.private_ml_class.id, f'Invalid pk "{self.private_ml_class.id}" - object does not exist.', 9), + ("je says pas", "“je says pas†is not a valid UUID.", 5), + (non_existent_id, f'Invalid pk "{non_existent_id}" - object does not exist.', 6), + (self.private_ml_class.id, f'Invalid pk "{self.private_ml_class.id}" - object does not exist.', 6), ] for ml_class_id, message, queries in cases: with self.subTest(ml_class_id=ml_class_id), self.assertNumQueries(queries): @@ -1556,7 +1541,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) process = Process.objects.create(mode=ProcessMode.Workers, corpus=self.corpus, creator=self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.patch( reverse("api:process-details", kwargs={"pk": process.id}), { @@ -1615,6 +1600,7 @@ class TestProcesses(FixtureAPITestCase): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.user_img_process.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_retry_repository_process_no_right(self): """ A user that is not the creator nor admin cannot restart a process that is not linked to any corpus @@ -1643,7 +1629,7 @@ class TestProcesses(FixtureAPITestCase): with self.subTest(state=state): self.elts_process.tasks.all().update(state=state) self.assertEqual(self.elts_process.state, state) - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"__all__": [message]}) @@ -1657,7 +1643,7 @@ class TestProcesses(FixtureAPITestCase): self.elts_process.finished = timezone.now() self.elts_process.save() - with self.assertNumQueries(16): + with self.assertNumQueries(11): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1668,6 +1654,7 @@ class TestProcesses(FixtureAPITestCase): # Activity initialization runs again self.assertFalse(delay_mock.called) + @expectedFailure def test_retry_farm_guest(self): self.elts_process.run() self.elts_process.tasks.all().update(state=State.Error) @@ -1700,7 +1687,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(10): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1713,7 +1700,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) process.worker_runs.create( version=self.version_with_model, @@ -1728,7 +1714,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(10): response = self.client.post(reverse("api:process-retry", kwargs={"pk": process.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1741,7 +1727,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) self.assertFalse(self.elts_process.tasks.exists()) - with self.assertNumQueries(15): + with self.assertNumQueries(10): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.elts_process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1763,7 +1749,7 @@ class TestProcesses(FixtureAPITestCase): self.workers_process.activity_state = ActivityState.Error self.workers_process.save() - with self.assertNumQueries(18): + with self.assertNumQueries(13): response = self.client.post(reverse("api:process-retry", kwargs={"pk": self.workers_process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1778,7 +1764,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( mode=ProcessMode.Files, creator=self.user, - farm=self.default_farm, ) process.worker_runs.create(version=self.version_with_model) process.tasks.create(state=State.Error, run=0, depth=0) @@ -1787,7 +1772,7 @@ class TestProcesses(FixtureAPITestCase): with ( self.settings(IMPORTS_WORKER_VERSION=str(self.version_with_model.id)), - self.assertNumQueries(19), + self.assertNumQueries(14), ): response = self.client.post(reverse("api:process-retry", kwargs={"pk": process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1808,7 +1793,6 @@ class TestProcesses(FixtureAPITestCase): folder_type=self.volume_type, element_type=self.page_type, creator=self.user, - farm=self.default_farm, ) process.worker_runs.create(version=self.version_with_model) process.tasks.create(state=State.Error, run=0, depth=0) @@ -1824,7 +1808,7 @@ class TestProcesses(FixtureAPITestCase): IMPORTS_WORKER_VERSION=str(self.version_with_model.id), INGEST_IMAGESERVER_ID=str(ImageServer.objects.get(url="http://server").id), ), - self.assertNumQueries(20), + self.assertNumQueries(15), ): response = self.client.post(reverse("api:process-retry", kwargs={"pk": process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1842,7 +1826,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( mode=ProcessMode.IIIF, creator=self.user, - farm=self.default_farm, ) process.worker_runs.create(version=self.version_with_model) process.tasks.create(state=State.Error, run=0, depth=0) @@ -1851,7 +1834,7 @@ class TestProcesses(FixtureAPITestCase): with ( self.settings(IMPORTS_WORKER_VERSION=str(self.version_with_model.id)), - self.assertNumQueries(19), + self.assertNumQueries(14), ): response = self.client.post(reverse("api:process-retry", kwargs={"pk": process.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1878,7 +1861,7 @@ class TestProcesses(FixtureAPITestCase): with ( self.settings(IMPORTS_WORKER_VERSION=str(self.version_with_model.id)), - self.assertNumQueries(30) + self.assertNumQueries(25), ): response = self.client.post(reverse("api:files-process"), { "files": [str(self.img_df.id)], @@ -1893,7 +1876,7 @@ class TestProcesses(FixtureAPITestCase): self.assertListEqual(list(process.files.all()), [self.img_df]) self.assertEqual(process.folder_type.slug, "volume") self.assertEqual(process.element_type.slug, "page") - self.assertEqual(process.farm, self.default_farm) + self.assertEqual(process.farm, None) self.assertEqual(self.version_with_model.worker_runs.count(), 1) import_task = process.tasks.get(slug="import_files") self.assertEqual( @@ -1932,7 +1915,7 @@ class TestProcesses(FixtureAPITestCase): for content_type in settings.ARCHIVE_MIME_TYPES ) - with self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)), self.assertNumQueries(42): + with self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)), self.assertNumQueries(37): response = self.client.post(reverse("api:files-process"), { "files": [ str(self.pdf_df.id), @@ -1965,7 +1948,7 @@ class TestProcesses(FixtureAPITestCase): def test_from_files_iiif(self): self.client.force_login(self.user) - with self.assertNumQueries(30), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): + with self.assertNumQueries(25), self.settings(IMPORTS_WORKER_VERSION=str(self.import_worker_version.id)): response = self.client.post(reverse("api:files-process"), { "files": [str(self.iiif_df.id)], "mode": "iiif", @@ -2074,7 +2057,7 @@ class TestProcesses(FixtureAPITestCase): def test_from_files_files_wrong_type(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.post(reverse("api:files-process"), { "files": [str(self.iiif_df.id)], "folder_type": "volume", @@ -2117,23 +2100,25 @@ class TestProcesses(FixtureAPITestCase): def test_from_files_farm(self): self.client.force_login(self.user) self.assertEqual(self.version_with_model.worker_runs.count(), 0) + farm = Farm.objects.get(name="Wheat farm") with ( self.settings(IMPORTS_WORKER_VERSION=str(self.version_with_model.id)), - self.assertNumQueries(30) + self.assertNumQueries(26), ): response = self.client.post(reverse("api:files-process"), { "files": [str(self.img_df.id)], "folder_type": "volume", "element_type": "page", - "farm_id": str(self.other_farm.id), + "farm_id": str(farm.id), }, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) data = response.json() process = Process.objects.get(id=data["id"]) - self.assertEqual(process.farm, self.other_farm) + self.assertEqual(process.farm, farm) + @expectedFailure def test_from_files_farm_guest(self): self.client.force_login(self.user) self.assertEqual(self.version_with_model.worker_runs.count(), 0) @@ -2155,26 +2140,6 @@ class TestProcesses(FixtureAPITestCase): "farm_id": ["You do not have access to this farm."], }) - def test_from_files_default_farm_guest(self): - self.client.force_login(self.user) - self.assertEqual(self.version_with_model.worker_runs.count(), 0) - self.default_farm.memberships.filter(user=self.user).delete() - - with ( - self.settings(IMPORTS_WORKER_VERSION=str(self.version_with_model.id)), - self.assertNumQueries(8) - ): - response = self.client.post(reverse("api:files-process"), { - "files": [str(self.img_df.id)], - "folder_type": "volume", - "element_type": "page", - }, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "farm_id": ["You do not have access to this farm."], - }) - def test_start_process_requires_login(self): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(self.user_img_process.id)}) @@ -2207,7 +2172,6 @@ class TestProcesses(FixtureAPITestCase): process2 = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) process2.run() self.assertTrue(process2.tasks.exists()) @@ -2227,7 +2191,6 @@ class TestProcesses(FixtureAPITestCase): process2 = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) process2.worker_runs.create(version=self.version_with_model, parents=[], configuration=None) process2.save() @@ -2247,7 +2210,6 @@ class TestProcesses(FixtureAPITestCase): process2 = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) run = process2.worker_runs.create(version=self.version_with_model, parents=[], configuration=None) run.model_version = self.model_version_1 @@ -2255,7 +2217,7 @@ class TestProcesses(FixtureAPITestCase): self.assertFalse(process2.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2266,11 +2228,10 @@ class TestProcesses(FixtureAPITestCase): process2 = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2285,7 +2246,6 @@ class TestProcesses(FixtureAPITestCase): process2 = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) process2.worker_runs.create(version=self.recognizer, parents=[], configuration=None) self.recognizer.state = WorkerVersionState.Error @@ -2293,7 +2253,7 @@ class TestProcesses(FixtureAPITestCase): self.assertFalse(process2.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2311,7 +2271,7 @@ class TestProcesses(FixtureAPITestCase): self.assertFalse(process2.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2329,7 +2289,7 @@ class TestProcesses(FixtureAPITestCase): self.assertFalse(process2.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2378,7 +2338,7 @@ class TestProcesses(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2402,7 +2362,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2411,8 +2371,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(response.json()["id"], str(process2.id)) process2.refresh_from_db() self.assertEqual(process2.state, State.Unscheduled) - # Ensure default parameters are used - self.assertEqual(process2.farm, self.default_farm) + self.assertEqual(process2.farm, None) self.assertEqual(process2.tasks.count(), 2) task1, task2 = process2.tasks.order_by("slug") self.assertEqual(task1.slug, "initialisation") @@ -2426,7 +2385,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2443,7 +2402,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2460,7 +2419,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}), { @@ -2486,7 +2445,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(18): + with self.assertNumQueries(12): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process2.id)}) ) @@ -2496,7 +2455,7 @@ class TestProcesses(FixtureAPITestCase): process2.refresh_from_db() self.assertEqual(process2.state, State.Unscheduled) # Ensure default parameters are used - self.assertEqual(process2.farm, self.default_farm) + self.assertEqual(process2.farm, None) self.assertEqual(process2.tasks.count(), 1) task = process2.tasks.get() self.assertEqual(task.slug, run.task_slug) @@ -2518,7 +2477,7 @@ class TestProcesses(FixtureAPITestCase): self.assertFalse(process.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}) ) @@ -2537,19 +2496,21 @@ class TestProcesses(FixtureAPITestCase): """ workers_process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) workers_process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) - self.client.force_login(self.user) + farm = Farm.objects.get(name="Wheat farm") - with self.assertNumQueries(20): + self.client.force_login(self.user) + with self.assertNumQueries(15): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(workers_process.id)}), - {"farm": str(self.other_farm.id)} + {"farm": str(farm.id)} ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) workers_process.refresh_from_db() self.assertEqual(workers_process.state, State.Unscheduled) - self.assertEqual(workers_process.farm_id, self.other_farm.id) + self.assertEqual(workers_process.farm_id, farm.id) + @expectedFailure def test_start_process_default_farm_guest(self): workers_process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) workers_process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) @@ -2569,6 +2530,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(workers_process.state, State.Unscheduled) self.assertIsNone(workers_process.farm) + @expectedFailure def test_start_process_farm_guest(self): workers_process = self.corpus.processes.create(creator=self.user, mode=ProcessMode.Workers) workers_process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) @@ -2597,7 +2559,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) wrong_farm_id = uuid.uuid4() - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(workers_process.id)}), {"farm": str(wrong_farm_id)} @@ -2616,7 +2578,7 @@ class TestProcesses(FixtureAPITestCase): ({"thumbnails": "gloubiboulga"}, {"thumbnails": ["Must be a valid boolean."]}) ] for (params, check) in wrong_params_checks: - with self.subTest(**params), self.assertNumQueries(11): + with self.subTest(**params), self.assertNumQueries(5): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)}), params) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), check) @@ -2630,7 +2592,7 @@ class TestProcesses(FixtureAPITestCase): process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}), {"chunks": 43}, @@ -2703,7 +2665,7 @@ class TestProcesses(FixtureAPITestCase): element_type=self.corpus.types.get(slug="page") ) self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(4): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}), {"use_cache": "true", "worker_activity": "true", "use_gpu": "true"} @@ -2735,7 +2697,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(21): + with self.assertNumQueries(15): response = self.client.post( reverse("api:process-start", kwargs={"pk": str(process.id)}), {"use_cache": "true", "worker_activity": "true", "use_gpu": "true"} @@ -2771,7 +2733,7 @@ class TestProcesses(FixtureAPITestCase): self.assertNotEqual(run_1.task_slug, run_2.task_slug) self.client.force_login(self.user) - with self.assertNumQueries(20): + with self.assertNumQueries(14): response = self.client.post(reverse("api:process-start", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -2793,7 +2755,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Files, - farm=self.default_farm, ) process.files.create(name="A PDF file", size=10e6, content_type="pdf", corpus=self.corpus) token_mock.return_value = "12345" @@ -2826,7 +2787,6 @@ class TestProcesses(FixtureAPITestCase): process = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, - farm=self.default_farm, ) token_mock.side_effect = [b"12345", b"78945"] run = process.worker_runs.create(version=self.version_with_model, parents=[], configuration=None) @@ -2873,7 +2833,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(process.worker_runs.count(), 2) self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.delete(reverse("api:clear-process", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) process.refresh_from_db() @@ -2936,7 +2896,6 @@ class TestProcesses(FixtureAPITestCase): creator=self.user, mode=ProcessMode.Workers, element_type=self.corpus.types.get(slug="page"), - farm=self.default_farm, ) process.worker_runs.create(version=self.dla, parents=[], configuration=None) process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) @@ -2946,7 +2905,7 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(process.state, State.Running) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:clear-process", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"__all__": ["A process can only be cleared before getting started."]}) @@ -2959,7 +2918,6 @@ class TestProcesses(FixtureAPITestCase): creator=self.user, mode=ProcessMode.Workers, element_type=self.corpus.types.get(slug="page"), - farm=self.default_farm, ) process.worker_runs.create(version=self.dla, parents=[], configuration=None) process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) @@ -2968,17 +2926,17 @@ class TestProcesses(FixtureAPITestCase): self.assertEqual(process.state, State.Unscheduled) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse("api:clear-process", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"__all__": ["A process can only be cleared before getting started."]}) + @expectedFailure def test_clear_process_requires_permissions(self): process = self.corpus.processes.create( creator=self.user, mode=ProcessMode.Workers, element_type=self.corpus.types.get(slug="page"), - farm=self.default_farm, ) process.worker_runs.create(version=self.dla, parents=[], configuration=None) process.worker_runs.create(version=self.recognizer, parents=[], configuration=None) @@ -2990,7 +2948,7 @@ class TestProcesses(FixtureAPITestCase): self.corpus.memberships.create(user=user2, level=Role.Contributor.value) self.client.force_login(user2) - with self.assertNumQueries(6): + with self.assertNumQueries(8): response = self.client.delete(reverse("api:clear-process", kwargs={"pk": str(process.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have a sufficient access level to this process."}) @@ -3021,6 +2979,7 @@ class TestProcesses(FixtureAPITestCase): response = self.client.post(reverse("api:process-select-failures", kwargs={"pk": str(uuid.uuid4())})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + @expectedFailure def test_select_failed_elts_requires_corpus_read_access(self): self.client.force_login(self.user) self.elts_process.corpus.memberships.filter(user=self.user).delete() @@ -3042,7 +3001,7 @@ class TestProcesses(FixtureAPITestCase): self.client.force_login(self.user) self.elts_process.activity_state = ActivityState.Disabled self.elts_process.save() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:process-select-failures", kwargs={"pk": str(self.elts_process.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -3091,7 +3050,7 @@ class TestProcesses(FixtureAPITestCase): state=WorkerActivityState.Processed, ) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:process-select-failures", kwargs={"pk": str(self.elts_process.id)}) ) @@ -3127,7 +3086,7 @@ class TestProcesses(FixtureAPITestCase): self.assertCountEqual(list(self.user.selections.values_list("element__name", flat=True)), []) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:process-select-failures", kwargs={"pk": str(self.elts_process.id)}) ) diff --git a/arkindex/process/tests/test_repos.py b/arkindex/process/tests/test_repos.py index d5c32e7a16..607efa3d20 100644 --- a/arkindex/process/tests/test_repos.py +++ b/arkindex/process/tests/test_repos.py @@ -54,19 +54,11 @@ class TestRepositories(FixtureTestCase): response = self.client.get(reverse("api:repository-list")) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_repository_external_user(self): - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:repository-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.json() - self.assertCountEqual(data["results"], []) - def test_list_repository_user_readable(self): self.repo.memberships.create(user=self.user, level=Role.Admin.value) self.repo_2.memberships.create(user=self.user, level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(6): response = self.client.get(reverse("api:repository-list")) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -99,14 +91,6 @@ class TestRepositories(FixtureTestCase): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) - def test_repository_retrieve_no_right(self): - self.client.force_login(self.user) - self.assertFalse(self.user.is_admin) - response = self.client.get( - reverse("api:repository-retrieve", kwargs={"pk": str(self.repo.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_repository_retrieve(self): """ An user with an access right on a repository is able to retrieve it without its clone URL @@ -138,7 +122,7 @@ class TestRepositories(FixtureTestCase): task = process.tasks.get() self.repo_2.memberships.create(user=self.user, level=Role.Guest.value) - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.client.get( reverse("api:repository-retrieve", kwargs={"pk": str(self.repo_2.id)}), HTTP_AUTHORIZATION=f"Ponos {task.token}", @@ -165,7 +149,7 @@ class TestRepositories(FixtureTestCase): task = process.tasks.get() self.repo_2.memberships.create(user=self.user, level=Role.Guest.value) - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.client.get( reverse("api:repository-retrieve", kwargs={"pk": str(self.repo_2.id)}), HTTP_AUTHORIZATION=f"Ponos {task.token}", @@ -182,7 +166,7 @@ class TestRepositories(FixtureTestCase): def test_repository_retrieve_disabled_repo(self): self.repo_2.memberships.create(user=self.user, level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(4): response = self.client.get(reverse("api:repository-retrieve", kwargs={"pk": str(self.repo_2.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() @@ -230,17 +214,6 @@ class TestRepositories(FixtureTestCase): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) - def test_revision_retrieve_read_right(self): - """ - A user with no right or a read right is not allowed to retrieve a revision - """ - self.repo.memberships.create(user=self.user, level=Role.Guest.value) - self.client.force_login(self.user) - response = self.client.get( - reverse("api:revision-retrieve", kwargs={"pk": str(self.rev.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_revision_retrieve_contributor_right(self): """ A user with a contributor access to the revision repo is allowed to retrieve information diff --git a/arkindex/process/tests/test_signals.py b/arkindex/process/tests/test_signals.py index 4fbbd69021..3719074bad 100644 --- a/arkindex/process/tests/test_signals.py +++ b/arkindex/process/tests/test_signals.py @@ -1,28 +1,10 @@ -import hashlib -import uuid -from datetime import datetime, timezone -from unittest.mock import patch -from django.urls import reverse -from rest_framework import status from rest_framework.exceptions import ValidationError -from arkindex.ponos.authentication import AgentUser -from arkindex.ponos.models import Farm, State -from arkindex.process.models import ( - ActivityState, - ProcessMode, - Repository, - Worker, - WorkerActivityState, - WorkerRun, - WorkerType, - WorkerVersion, -) +from arkindex.ponos.models import Farm +from arkindex.process.models import ProcessMode, Repository, Worker, WorkerRun, WorkerType, WorkerVersion from arkindex.process.signals import _list_ancestors from arkindex.project.tests import FixtureAPITestCase -from arkindex.project.tools import build_public_key -from arkindex.training.models import Model class TestSignals(FixtureAPITestCase): @@ -72,19 +54,6 @@ class TestSignals(FixtureAPITestCase): mode=ProcessMode.Workers, farm=cls.farm, ) - pubkey = build_public_key() - cls.agent = AgentUser.objects.create( - id=uuid.UUID(hashlib.md5(pubkey.encode("utf-8")).hexdigest()), - farm=cls.farm, - hostname="ghostname", - cpu_cores=2, - cpu_frequency=1e9, - public_key=pubkey, - ram_total=2e9, - last_ping=datetime.now(), - cpu_load=.1, - ram_load=.1e9, - ) def test_worker_run_check_parents_recursive(self): run_2 = self.process_1.worker_runs.create( @@ -292,156 +261,3 @@ class TestSignals(FixtureAPITestCase): ) self.assertIsNotNone(run.summary) - - @patch("arkindex.ponos.serializers.TaskSerializer.get_logs") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - def test_task_failure_updates_activities(self, notify_mock, get_logs_mock): - """ - A Ponos task failure should update `Started` worker activities to `Error`. - """ - get_logs_mock.return_value = None - - self.process_2.activity_state = ActivityState.Ready - self.process_2.save() - self.process_2.run() - task = self.process_2.tasks.first() - - # Create one activity per WorkerActivityState on random elements - element1, element2, element3, element4 = self.corpus.elements.all()[:4] - queued_activity = self.process_2.activities.create( - worker_version=self.version_1, - element=element1, - state=WorkerActivityState.Queued, - ) - processed_activity = self.process_2.activities.create( - worker_version=self.version_1, - element=element3, - state=WorkerActivityState.Processed, - ) - error_activity = self.process_2.activities.create( - worker_version=self.version_1, - element=element4, - state=WorkerActivityState.Error, - ) - - # Some state changes are only allowed when a task is in a specific state, - # so our cases list has an initial task state, a state to update the task to, - # and an expected activity state for `started_activity` - cases = [ - (State.Pending, State.Running, WorkerActivityState.Started), - (State.Running, State.Error, WorkerActivityState.Error), - (State.Running, State.Failed, WorkerActivityState.Error), - (State.Running, State.Completed, WorkerActivityState.Started), - (State.Stopping, State.Stopped, WorkerActivityState.Error), - ] - - started_activity = None - for old_task_state, new_task_state, expected_activity_state in cases: - # Reset activity state between each case - if started_activity: - started_activity.delete() - started_activity = self.process_2.activities.create( - worker_version=self.version_1, - element=element2, - state=WorkerActivityState.Started, - started=datetime.now(timezone.utc), - ) - task.state = old_task_state - task.agent = self.agent - task.save() - with self.subTest(old_state=old_task_state, new_state=new_task_state): - resp = self.client.patch( - reverse("api:task-details", kwargs={"pk": task.id}), - data={"state": new_task_state.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - task.refresh_from_db() - self.assertEqual(task.state, new_task_state) - - queued_activity.refresh_from_db() - started_activity.refresh_from_db() - processed_activity.refresh_from_db() - error_activity.refresh_from_db() - - self.assertEqual(queued_activity.state, WorkerActivityState.Queued) - self.assertEqual(processed_activity.state, WorkerActivityState.Processed) - self.assertEqual(error_activity.state, WorkerActivityState.Error) - self.assertEqual(started_activity.state, expected_activity_state) - - @patch("arkindex.ponos.serializers.TaskSerializer.get_logs") - @patch("arkindex.ponos.tasks.notify_process_completion.delay") - def test_task_failure_filters_activities_worker_run(self, notify_mock, get_logs_mock): - """ - A Ponos task failure only updates worker activity with the same worker version, - model version and worker configuration. - """ - get_logs_mock.return_value = None - model = Model.objects.create(name="Generic model", public=False) - model_version = model.versions.create(hash="b" * 32, archive_hash="a" * 32, size=8) - worker_configuration = self.worker_1.configurations.create(name="conf") - self.run_1.configuration = worker_configuration - self.run_1.save() - - self.process_2.activity_state = ActivityState.Ready - self.process_2.save() - self.process_2.run() - task = self.process_2.tasks.first() - task.state = State.Running - task.agent = self.agent - task.save() - - # Create one activity per WorkerActivityState on random elements - element = self.corpus.elements.first() - activity_1 = self.process_2.activities.create( - worker_version=self.version_1, - configuration=worker_configuration, - model_version=None, - element=element, - started=datetime.now(), - ) - activity_2 = self.process_2.activities.create( - worker_version=self.version_1, - configuration=None, - model_version=model_version, - element=element, - started=datetime.now(), - ) - activity_3 = self.process_2.activities.create( - worker_version=self.version_1, - configuration=worker_configuration, - model_version=model_version, - element=element, - started=datetime.now(), - ) - - cases = [ - (None, None, None, [activity_1, activity_2, activity_3]), - (self.run_1, model_version, None, [activity_2]), - (self.run_1, None, worker_configuration, [activity_1]), - (self.run_1, model_version, worker_configuration, [activity_3]), - ] - for worker_run, model_version, worker_configuration, expected in cases: - self.process_2.activities.update(state=WorkerActivityState.Started) - if worker_run: - worker_run.model_version = model_version - worker_run.configuration = worker_configuration - worker_run.save() - task.worker_run = worker_run - task.save() - - with self.subTest( - worker_run=worker_run, - model_version=model_version, - worker_configuration=worker_configuration - ): - resp = self.client.patch( - reverse("api:task-details", kwargs={"pk": task.id}), - data={"state": State.Error.value}, - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - for activity in self.process_2.activities.filter(id__in=[e.id for e in expected]): - self.assertEqual(activity.state, WorkerActivityState.Error) - for activity in self.process_2.activities.exclude(id__in=[e.id for e in expected]): - self.assertEqual(activity.state, WorkerActivityState.Started) diff --git a/arkindex/process/tests/test_templates.py b/arkindex/process/tests/test_templates.py index 753421c531..49f8b82e94 100644 --- a/arkindex/process/tests/test_templates.py +++ b/arkindex/process/tests/test_templates.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timezone +from unittest import expectedFailure from rest_framework import status from rest_framework.reverse import reverse @@ -88,7 +89,7 @@ class TestTemplates(FixtureAPITestCase): def test_create(self): self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(9): response = self.client.post( reverse( "api:create-process-template", kwargs={"pk": str(self.process_template.id)} @@ -138,6 +139,7 @@ class TestTemplates(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @expectedFailure def test_create_requires_contributor_access_rights_process(self): new_user = User.objects.create(email="new@test.fr", verified_email=True) self.worker_1.memberships.create(user=new_user, level=Role.Contributor.value) @@ -155,6 +157,7 @@ class TestTemplates(FixtureAPITestCase): {"detail": "You do not have a contributor access to this process."}, ) + @expectedFailure def test_create_requires_access_rights_all_workers(self): new_user = User.objects.create(email="new@test.fr", verified_email=True) self.private_corpus.memberships.create(user=new_user, level=Role.Contributor.value) @@ -177,7 +180,7 @@ class TestTemplates(FixtureAPITestCase): self.process.corpus = None if mode == ProcessMode.Repository else self.corpus self.process.save() - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:create-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -193,7 +196,7 @@ class TestTemplates(FixtureAPITestCase): self.client.force_login(self.user) local_process = self.user.processes.get(mode=ProcessMode.Local) - with self.assertNumQueries(11): + with self.assertNumQueries(5): response = self.client.post( reverse("api:create-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(local_process.id)}), @@ -205,6 +208,7 @@ class TestTemplates(FixtureAPITestCase): local_process.refresh_from_db() self.assertEqual(local_process.template, None) + @expectedFailure def test_apply_requires_contributor_rights_on_template(self): """Raise 403 if the user does not have rights on template """ @@ -227,6 +231,7 @@ class TestTemplates(FixtureAPITestCase): {"detail": "You do not have a contributor access to this process."}, ) + @expectedFailure def test_apply_requires_contributor_rights_on_process(self): """Raise 403 if the user does not have rights on the target process """ @@ -249,6 +254,7 @@ class TestTemplates(FixtureAPITestCase): {"detail": "You do not have a contributor access to this process."}, ) + @expectedFailure def test_apply_requires_access_rights_all_workers(self): """Raise 403 if the user does not have rights on all workers concerned """ @@ -303,7 +309,7 @@ class TestTemplates(FixtureAPITestCase): def test_apply(self): self.assertIsNotNone(self.version_2.docker_image_id) self.client.force_login(self.user) - with self.assertNumQueries(18): + with self.assertNumQueries(11): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -336,7 +342,7 @@ class TestTemplates(FixtureAPITestCase): self.version_2.save() self.client.force_login(self.user) - with self.assertNumQueries(18): + with self.assertNumQueries(11): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -356,7 +362,7 @@ class TestTemplates(FixtureAPITestCase): parents=[], ) # Apply a template that has two other worker runs - with self.assertNumQueries(20): + with self.assertNumQueries(13): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(process.id)}), @@ -384,7 +390,7 @@ class TestTemplates(FixtureAPITestCase): self.version_2.state = WorkerVersionState.Error self.version_2.save() self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(7): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -399,7 +405,7 @@ class TestTemplates(FixtureAPITestCase): self.worker_2.archived = datetime.now(timezone.utc) self.worker_2.save() self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(7): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -415,7 +421,7 @@ class TestTemplates(FixtureAPITestCase): self.model_version.save() self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(7): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -433,7 +439,7 @@ class TestTemplates(FixtureAPITestCase): self.model.save() self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(7): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -452,7 +458,7 @@ class TestTemplates(FixtureAPITestCase): self.process.mode = mode self.process.save() - with self.assertNumQueries(14): + with self.assertNumQueries(7): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(self.process.id)}), @@ -470,7 +476,7 @@ class TestTemplates(FixtureAPITestCase): self.client.force_login(self.user) local_process = self.user.processes.get(mode=ProcessMode.Local) - with self.assertNumQueries(12): + with self.assertNumQueries(6): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(local_process.id)}), @@ -488,7 +494,7 @@ class TestTemplates(FixtureAPITestCase): self.client.force_login(self.user) process = Process.objects.filter(mode=ProcessMode.Repository).first() - with self.assertNumQueries(12): + with self.assertNumQueries(6): response = self.client.post( reverse("api:apply-process-template", kwargs={"pk": str(self.template.id)}), data=json.dumps({"process_id": str(process.id)}), @@ -504,7 +510,7 @@ class TestTemplates(FixtureAPITestCase): def test_list_ignores_configuration_filter(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get( reverse("api:process-list"), data={"mode": "template", "with_tasks": True}, diff --git a/arkindex/process/tests/test_user_workerruns.py b/arkindex/process/tests/test_user_workerruns.py index f42698d2de..69c05a1d5b 100644 --- a/arkindex/process/tests/test_user_workerruns.py +++ b/arkindex/process/tests/test_user_workerruns.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import patch +from unittest.mock import call, patch from django.urls import reverse from django.utils import timezone @@ -15,7 +15,7 @@ from arkindex.process.models import ( WorkerVersionState, ) from arkindex.project.tests import FixtureAPITestCase -from arkindex.training.models import Model, ModelVersionState +from arkindex.training.models import Model, ModelVersion, ModelVersionState from arkindex.users.models import Right, Role, User @@ -203,7 +203,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): def test_create_user_run(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id)} @@ -257,7 +257,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): Right.objects.create(user=test_user, content_object=self.other_version.worker, level=Role.Contributor.value) self.client.force_login(test_user) self.assertFalse(Process.objects.filter(mode=ProcessMode.Local, creator=test_user).exists()) - with self.assertNumQueries(10): + with self.assertNumQueries(8): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id)} @@ -278,7 +278,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): def test_create_user_run_wv_has_revision(self): Right.objects.create(user=self.user, content_object=self.version_1.worker, level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.version_1.id)} @@ -290,7 +290,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): Right.objects.create(user=self.user, content_object=self.version_1.worker, level=Role.Contributor.value) test_version = self.version_1.worker.versions.create(configuration={}, version=7) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(test_version.id)} @@ -303,7 +303,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): self.custom_version.worker.save() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.custom_version.id)} @@ -312,21 +312,25 @@ class TestUserWorkerRuns(FixtureAPITestCase): self.assertEqual(response.json(), {"worker_version_id": ["The worker used to create a local worker run must not be archived."]}) - def test_create_user_run_worker_not_executable(self): - test_user = User.objects.get(email="user2@user.fr") - Right.objects.create(user=test_user, content_object=self.other_version.worker, level=Role.Guest.value) - self.client.force_login(test_user) - with self.assertNumQueries(6): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Guest.value) + def test_create_user_run_worker_not_executable(self, get_max_level_mock): + self.client.force_login(self.user) + + with self.assertNumQueries(4): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id)} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"worker_version_id": ["You do not have contributor access to this worker."]}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.other_version.worker)) + def test_create_user_run_mv_doesnt_exist(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id), "model_version_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} @@ -336,7 +340,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): def test_create_user_run_wc_doesnt_exist(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id), "configuration_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} @@ -344,23 +348,31 @@ class TestUserWorkerRuns(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"configuration_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.']}) - def test_create_user_run_model_no_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=ModelVersion.objects.none()) + def test_create_user_run_model_no_access(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(10): + + with self.assertNumQueries(5): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id), "model_version_id": str(self.model_version.id)} ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), {"model_version_id": ["You do not have guest access to this model."]}) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Guest.value), + call(self.user, Model, Role.Contributor.value), + ]) + def test_create_user_run_model_archived(self): self.model.archived = datetime.now(timezone.utc) self.model.save() self.model.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:user-worker-run-create"), data={"worker_version_id": str(self.other_version.id), "model_version_id": str(self.model_version.id)} @@ -378,7 +390,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.post( reverse("api:user-worker-run-create"), data={ @@ -445,7 +457,7 @@ class TestUserWorkerRuns(FixtureAPITestCase): def test_create_user_run_duplicate(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:user-worker-run-create"), data={ diff --git a/arkindex/process/tests/test_utils.py b/arkindex/process/tests/test_utils.py deleted file mode 100644 index 6c6651e6ac..0000000000 --- a/arkindex/process/tests/test_utils.py +++ /dev/null @@ -1,67 +0,0 @@ -import uuid - -from django.test import TestCase, override_settings - -from arkindex.ponos.models import Farm -from arkindex.process.utils import get_default_farm - -DEFAULT_FARM_ID = str(uuid.uuid4()) - - -class TestProcessUtils(TestCase): - """ - Test utilities functions for process module - """ - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.corn_farm = Farm.objects.create(id=DEFAULT_FARM_ID, name="Corn farm") - cls.barley_farm = Farm.objects.create(name="Barley farm") - - def setUp(self): - super().setUp() - # Force clean the module default farm cached global variable - from arkindex.process import utils - setattr(utils, "__default_farm", None) - - @override_settings(PONOS_DEFAULT_FARM=DEFAULT_FARM_ID) - def test_config_ponos_farm_setting(self): - """ - Default farm may be defined in settings - """ - with self.assertNumQueries(1): - self.assertEqual(str(get_default_farm()), str(self.corn_farm)) - - def test_default_ponos_farm(self): - """ - In case no default farm is defined, a random farm is selected as default. - Subsequent calls does not hit the database - """ - self.assertEqual(Farm.objects.count(), 2) - - with self.assertNumQueries(1): - farm = get_default_farm() - self.assertIsInstance(farm, Farm) - - # No new farm was created - self.assertEqual(Farm.objects.count(), 2) - - with self.assertNumQueries(0): - self.assertEqual(get_default_farm(), farm) - - def test_default_farm_no_existing_farm(self): - """ - In case no farm exists, a default one is created - """ - Farm.objects.all().delete() - self.assertEqual(Farm.objects.count(), 0) - - with self.assertNumQueries(2): - farm = get_default_farm() - self.assertIsInstance(farm, Farm) - - self.assertTrue(Farm.objects.filter(id=farm.id).exists()) - - with self.assertNumQueries(0): - self.assertEqual(get_default_farm(), farm) diff --git a/arkindex/process/tests/test_worker_configurations.py b/arkindex/process/tests/test_worker_configurations.py index e56799ee04..e1e7b9df25 100644 --- a/arkindex/process/tests/test_worker_configurations.py +++ b/arkindex/process/tests/test_worker_configurations.py @@ -1,3 +1,5 @@ +from unittest.mock import call, patch + from django.urls import reverse from rest_framework import status @@ -30,55 +32,32 @@ class TestWorkerConfigurations(FixtureAPITestCase): def test_list_requires_login(self): with self.assertNumQueries(0): response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_requires_verified(self): self.user.verified_email = False self.user.save() self.client.force_login(self.user) + with self.assertNumQueries(2): response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_no_rights(self): - self.worker_1.repository.memberships.filter(user=self.user).delete() - self.worker_1.memberships.filter(user=self.user).delete() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_no_rights(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have a guest access to this worker."}) - def test_list_worker_rights(self): - """ - A user is able to list worker configurations if they have a guest access on the worker - """ - config_1 = self.worker_2.configurations.create(name="config_1", configuration={"key": "value"}) - config_2 = self.worker_2.configurations.create(name="config_2", configuration={"dulce": "et decorum est"}) - self.worker_2.memberships.all().delete() - self.worker_2.repository.memberships.all().delete() - self.worker_2.memberships.create(user=self.user, level=Role.Guest.value) - - self.client.force_login(self.user) - with self.assertNumQueries(7): - response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertListEqual(response.json()["results"], [{ - "id": str(config_1.id), - "name": "config_1", - "configuration": {"key": "value"}, - "archived": False, - }, { - "id": str(config_2.id), - "name": "config_2", - "configuration": {"dulce": "et decorum est"}, - "archived": False, - }]) + self.assertEqual(response.json(), {"detail": "You do not have a guest access to this worker."}) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Guest.value, skip_public=False), + call(self.user, self.repo, Role.Guest.value, skip_public=False), + ]) - def test_list_repo_rights(self): - """ - A user is able to list worker configurations if they have a guest access on the repository - """ + def test_list(self): config_1 = self.worker_2.configurations.create(name="config_1", configuration={"key": "value"}) config_2 = self.worker_2.configurations.create(name="config_2", configuration={"dulce": "et decorum est"}) # Only non-archived configurations should be listed @@ -87,14 +66,12 @@ class TestWorkerConfigurations(FixtureAPITestCase): configuration={"deprecated": "config"}, archived=True, ) - self.worker_2.memberships.all().delete() - self.worker_2.repository.memberships.all().delete() - self.worker_2.repository.memberships.create(user=self.user, level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.json()["results"], [{ "id": str(config_1.id), "name": "config_1", @@ -119,7 +96,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.get(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)}), {"archived": "true"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -143,29 +120,23 @@ class TestWorkerConfigurations(FixtureAPITestCase): response = self.client.post(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_no_rights(self): - self.worker_1.repository.memberships.filter(user=self.user).delete() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_no_rights(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + + with self.assertNumQueries(3): response = self.client.post(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "You do not have contributor access to this worker."}) - def test_create_contributor_ok(self): - self.worker_1.repository.memberships.filter(user=self.user).delete() - self.worker_1.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - with self.assertNumQueries(7): - response = self.client.post( - reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)}), - data={"name": "test_name", "configuration": {"aa": "bb"}}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json(), {"detail": "You do not have contributor access to this worker."}) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Contributor.value, skip_public=False), + call(self.user, self.repo, Role.Contributor.value, skip_public=False), + ]) def test_create_empty(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post(reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -179,7 +150,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_2.configurations.create(name=name, configuration={"key": "value"}) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": name, "configuration": {"key": "value", "cahuete": "bidule"}}, @@ -197,7 +168,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): test_config = self.worker_2.configurations.create(name="config-name", configuration=config) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": "New configuration", "configuration": config}, @@ -217,7 +188,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): test_config = self.worker_2.configurations.create(name="a config name", configuration=config) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": "a config name", "configuration": config}, @@ -240,7 +211,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): test_config_2 = self.worker_2.configurations.create(name="some config", configuration=config_2) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": "a config name", "configuration": config_2}, @@ -266,7 +237,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_2.configurations.create(name="config_1", configuration={"key": "value"}) self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": name, "configuration": config}, @@ -287,7 +258,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_2.configurations.create(name="config_1", configuration={"key": "value"}) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_2.id)}), data={"name": name}, @@ -298,7 +269,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): def test_create_not_json(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)}), data={"name": "test_config", "configuration": []}, format="json" @@ -310,7 +281,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): def test_create_null_config(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)}), data={"name": "test_config", "configuration": None}, format="json" @@ -322,7 +293,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): def test_create_empty_config(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-configurations", kwargs={"pk": str(self.worker_1.id)}), data={"name": "test_config", "configuration": {}}, format="json" @@ -353,18 +324,24 @@ class TestWorkerConfigurations(FixtureAPITestCase): response = self.client.get(reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_retrieve_no_rights(self): - self.worker_1.repository.memberships.filter(user=self.user).delete() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_retrieve_no_rights(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(7): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), {"detail": "You do not have a guest access to this worker."}) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Guest.value, skip_public=False), + call(self.user, self.repo, Role.Guest.value, skip_public=False), + ]) def test_retrieve(self): self.worker_config.worker.memberships.get_or_create(user=self.user, level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(response.json(), { @@ -394,79 +371,33 @@ class TestWorkerConfigurations(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_no_rights(self): - """ - Cannot update a configuration without any access rights - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_requires_contributor(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={"archived": True} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), { - "detail": "You do not have a contributor access to this worker.", - }) - def test_update_guest_rights(self): - """ - Cannot update a configuration when only having guest rights - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.worker_1.memberships.create(user=self.user, level=Role.Guest.value) - self.worker_1.repository.memberships.create(user=self.user, level=Role.Guest.value) - self.client.force_login(self.user) - - with self.assertNumQueries(6): - response = self.client.put( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True} - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { "detail": "You do not have a contributor access to this worker.", }) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Contributor.value, skip_public=False), + call(self.user, self.repo, Role.Contributor.value, skip_public=False), + ]) - def test_update_contributor_repository(self): - """ - Can update a configuration with contributor rights on the repository - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.worker_1.repository.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - - self.assertFalse(self.worker_config.archived) - - with self.assertNumQueries(8): - response = self.client.put( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True, "name": "new name"} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.worker_config.refresh_from_db() - self.assertEqual(self.worker_config.name, "new name") - self.assertDictEqual(self.worker_config.configuration, {"key": "value"}) - self.assertTrue(self.worker_config.archived) - - def test_update_contributor_worker(self): + def test_update(self): """ Can update a configuration with contributor rights on the worker """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.worker_1.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) - self.assertFalse(self.worker_config.archived) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.put( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={"archived": True, "name": "new name"} @@ -478,28 +409,6 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.assertDictEqual(self.worker_config.configuration, {"key": "value"}) self.assertTrue(self.worker_config.archived) - def test_update_admin(self): - """ - Admins can update any configuration - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.client.force_login(self.superuser) - - self.assertFalse(self.worker_config.archived) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True, "name": "a new name"} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.worker_config.refresh_from_db() - self.assertEqual(self.worker_config.name, "a new name") - self.assertDictEqual(self.worker_config.configuration, {"key": "value"}) - self.assertTrue(self.worker_config.archived) - def test_update_ignored_fields(self): """ Fields that should not be editable are ignored when sent in update requests @@ -539,7 +448,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_config.save() self.assertTrue(self.worker_config.archived) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.put( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={ @@ -567,7 +476,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_config.save() self.assertTrue(self.worker_config.archived) - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.put( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={ @@ -590,7 +499,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_1.configurations.create(name="new name", configuration={"key": "value", "key2": "value2"}) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.put( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={ @@ -625,68 +534,26 @@ class TestWorkerConfigurations(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_no_rights(self): - """ - Cannot update a configuration without any access rights - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_requires_contributor(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={"archived": True} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), { - "detail": "You do not have a contributor access to this worker.", - }) - - def test_partial_update_guest_rights(self): - """ - Cannot update a configuration when only having guest rights - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.worker_1.memberships.create(user=self.user, level=Role.Guest.value) - self.worker_1.repository.memberships.create(user=self.user, level=Role.Guest.value) - self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True} - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { "detail": "You do not have a contributor access to this worker.", }) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Contributor.value, skip_public=False), + call(self.user, self.repo, Role.Contributor.value, skip_public=False), + ]) - def test_partial_update_contributor_repository(self): - """ - Can partial update a configuration with contributor rights on the repository - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.worker_1.repository.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - - self.assertFalse(self.worker_config.archived) - - with self.assertNumQueries(7): - response = self.client.patch( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.worker_config.refresh_from_db() - self.assertEqual(self.worker_config.name, "config time") - self.assertDictEqual(self.worker_config.configuration, {"key": "value"}) - self.assertTrue(self.worker_config.archived) - - def test_partial_update_contributor_worker(self): + def test_partial_update(self): """ Can partial update a configuration with contributor rights on the worker """ @@ -697,28 +564,6 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.assertFalse(self.worker_config.archived) - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), - data={"archived": True} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.worker_config.refresh_from_db() - self.assertEqual(self.worker_config.name, "config time") - self.assertDictEqual(self.worker_config.configuration, {"key": "value"}) - self.assertTrue(self.worker_config.archived) - - def test_partial_update_admin(self): - """ - Admins can partial update any configuration - """ - self.worker_1.memberships.all().delete() - self.worker_1.repository.memberships.all().delete() - self.client.force_login(self.superuser) - - self.assertFalse(self.worker_config.archived) - with self.assertNumQueries(4): response = self.client.patch( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), @@ -770,7 +615,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_config.save() self.assertTrue(self.worker_config.archived) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={ @@ -796,7 +641,7 @@ class TestWorkerConfigurations(FixtureAPITestCase): self.worker_1.configurations.create(name="new name", configuration={"key": "value", "key2": "value2"}) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:configuration-retrieve", kwargs={"pk": str(self.worker_config.id)}), data={ diff --git a/arkindex/process/tests/test_workeractivity.py b/arkindex/process/tests/test_workeractivity.py index 0585e9cf52..4520f37c3a 100644 --- a/arkindex/process/tests/test_workeractivity.py +++ b/arkindex/process/tests/test_workeractivity.py @@ -799,10 +799,11 @@ class TestWorkerActivity(FixtureTestCase): response = self.client.get(reverse("api:corpus-activity", kwargs={"corpus": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_corpus_acl(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_corpus_acl(self, has_access_mock): private_corpus = Corpus.objects.create(name="can't touch this") self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:corpus-activity", kwargs={"corpus": private_corpus.id})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have guest access to this corpus."}) @@ -896,7 +897,7 @@ class TestWorkerActivity(FixtureTestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.get( reverse("api:corpus-activity", kwargs={"corpus": self.corpus.id}), {"process_id": str(process.id)} diff --git a/arkindex/process/tests/test_workeractivity_stats.py b/arkindex/process/tests/test_workeractivity_stats.py index b7b9c51d1b..3e8e292a6e 100644 --- a/arkindex/process/tests/test_workeractivity_stats.py +++ b/arkindex/process/tests/test_workeractivity_stats.py @@ -1,6 +1,7 @@ import itertools import uuid from datetime import datetime, timedelta, timezone +from unittest.mock import patch from django.db.models.functions import Now from django.urls import reverse @@ -121,9 +122,10 @@ class TestWorkerActivityStats(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_corpus_private(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_corpus_private(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get( reverse("api:corpus-activity-stats", kwargs={"corpus": str(self.private_corpus.id)}) ) @@ -148,7 +150,7 @@ class TestWorkerActivityStats(FixtureAPITestCase): user.save() self.private_corpus.memberships.create(user=user, level=Role.Guest.value) self.client.force_login(user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:corpus-activity-stats", kwargs={"corpus": str(self.private_corpus.id)}) ) @@ -351,16 +353,17 @@ class TestWorkerActivityStats(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_process_private(self): + @patch("arkindex.project.mixins.get_max_level", return_value=None) + def test_process_private(self, get_max_level_mock): # An inaccessible project self.process_1.corpus = self.private_corpus self.process_1.save() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get( reverse("api:process-activity-stats", kwargs={"pk": str(self.process_1.id)}) ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_process_not_found(self): self.client.force_login(self.user) @@ -372,7 +375,7 @@ class TestWorkerActivityStats(FixtureAPITestCase): def test_process(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.get( reverse("api:process-activity-stats", kwargs={"pk": str(self.process_1.id)}) ) diff --git a/arkindex/process/tests/test_workerruns.py b/arkindex/process/tests/test_workerruns.py index e66ecfc19f..d0a6f75d27 100644 --- a/arkindex/process/tests/test_workerruns.py +++ b/arkindex/process/tests/test_workerruns.py @@ -1,5 +1,7 @@ import uuid from datetime import datetime, timezone +from unittest import expectedFailure +from unittest.mock import call, patch from django.test import override_settings from django.urls import reverse @@ -110,7 +112,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.worker_1.memberships.all().delete() self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.get(reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -119,7 +121,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_list(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.get(reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -175,7 +177,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.get(reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -194,14 +196,15 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_create_no_execution_right(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_no_execution_right(self, has_access_mock): """ An execution access on the target worker version is required to create a worker run """ self.worker_1.memberships.update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(6): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "parents": []}, @@ -211,11 +214,16 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertEqual(response.json(), {"worker_version_id": ["You do not have an execution access to this worker."]}) + self.assertListEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_1, Role.Contributor.value, skip_public=False), + call(self.user, self.repo, Role.Contributor.value, skip_public=False), + ]) + def test_create_invalid_version(self): self.client.force_login(self.user) version_id = uuid.uuid4() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": version_id}, @@ -243,8 +251,8 @@ class TestWorkerRuns(FixtureAPITestCase): self.run_1.configuration = configuration self.run_1.save() - # Having a model version set adds two queries, having a configuration adds one - query_count = 11 + bool(model_version) * 2 + bool(configuration) + # Having a model version or a configuration adds one query for each + query_count = 6 + bool(model_version) + bool(configuration) with self.assertNumQueries(query_count): response = self.client.post( @@ -267,7 +275,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.version_1.save() self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "parents": []}, @@ -282,7 +290,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.worker_1.save() self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "parents": []}, @@ -299,7 +307,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.version_1.save() self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(7): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "model_version_id": str(self.model_version_1.id)}, @@ -330,7 +338,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.process_2.mode = mode self.process_2.save() - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), {"worker_version_id": str(self.version_1.id)}, @@ -369,7 +377,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(process.id)}), data={"worker_version_id": str(self.version_1.id), "parents": []}, @@ -391,7 +399,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.process_2.mode = mode self.process_2.save() - with self.assertNumQueries(13): + with self.assertNumQueries(8): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), {"worker_version_id": str(self.version_1.id), "parents": []}, @@ -453,7 +461,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.worker_1.repository.memberships.create(user=self.user, level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(8): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "parents": []}, @@ -467,7 +475,7 @@ class TestWorkerRuns(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}) ) @@ -480,7 +488,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_create_configuration(self): self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(9): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={ @@ -549,7 +557,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_create_invalid_configuration(self): self.client.force_login(self.user) - with self.assertNumQueries(12): + with self.assertNumQueries(7): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(self.version_1.id), "parents": [], "configuration_id": str(self.configuration_2.id)}, @@ -589,7 +597,7 @@ class TestWorkerRuns(FixtureAPITestCase): docker_image_id=self.version_1.docker_image_id, ) - with self.assertNumQueries(13): + with self.assertNumQueries(8): response = self.client.post( reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), data={"worker_version_id": str(version.id), "parents": []}, @@ -658,7 +666,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.worker_1.memberships.update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.get( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -720,7 +728,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_retrieve(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -849,7 +857,7 @@ class TestWorkerRuns(FixtureAPITestCase): # Check that the gitrefs are retrieved with RetrieveWorkerRun self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -963,10 +971,10 @@ class TestWorkerRuns(FixtureAPITestCase): """ self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) - with self.assertNumQueries(6): + self.client.force_login(self.user) + with self.assertNumQueries(7): response = self.client.get( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1014,23 +1022,25 @@ class TestWorkerRuns(FixtureAPITestCase): "summary": f"Worker Recognizer @ {str(self.version_1.id)[:6]}", }) + @expectedFailure def test_retrieve_agent_unassigned(self): """ A Ponos agent cannot retrieve a WorkerRun on a process where it does not have any assigned tasks """ self.process_1.tasks.create(run=0, depth=0, slug="something", agent=None) - with self.assertNumQueries(2): + # Agent auth is not implemented in CE + self.client.force_login(self.user) + with self.assertNumQueries(3): response = self.client.get( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_update_requires_nothing(self): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={}, @@ -1064,14 +1074,15 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_update_no_project_admin_right(self): + @patch("arkindex.project.mixins.get_max_level", return_value=Role.Contributor.value) + def test_update_no_project_admin_right(self, get_max_level_mock): """ - A user cannot update a worker run if he has no admin access on its process project + A user cannot update a worker run if they have no admin access on its process project """ - self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1082,6 +1093,9 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertEqual(response.json(), {"detail": "You do not have an admin access to this process."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + def test_update_invalid_process_mode(self): """ A user cannot update a worker run on a local process @@ -1132,7 +1146,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_update_nonexistent_parent(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1152,7 +1166,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.client.force_login(self.user) run_2 = self.process_1.worker_runs.create(version=self.version_2) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1188,7 +1202,7 @@ class TestWorkerRuns(FixtureAPITestCase): Process field cannot be updated """ self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1251,7 +1265,7 @@ class TestWorkerRuns(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1313,7 +1327,7 @@ class TestWorkerRuns(FixtureAPITestCase): # Check generated summary, before updating, it should not be that verbose self.assertEqual(self.run_1.summary, f"Worker Recognizer @ {str(self.run_1.version.id)[:6]}") - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), data={ @@ -1382,7 +1396,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.client.force_login(self.user) self.assertEqual(self.run_1.configuration, None) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), data={"parents": [], "configuration_id": str(self.configuration_2.id)}, @@ -1400,7 +1414,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertTrue(self.process_1.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1435,7 +1449,7 @@ class TestWorkerRuns(FixtureAPITestCase): parents=[], ) - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -1472,7 +1486,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) random_model_version_uuid = str(uuid.uuid4()) - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -1487,7 +1501,8 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] }) - def test_update_model_version_no_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=ModelVersion.objects.none()) + def test_update_model_version_no_access(self, filter_rights_mock): """ Cannot update a worker run with a model_version UUID, when you don't have access to the model version """ @@ -1518,7 +1533,7 @@ class TestWorkerRuns(FixtureAPITestCase): archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -1533,7 +1548,13 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{model_version_no_access.id}" - object does not exist.'], }) - def test_update_model_version_guest(self): + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Guest.value), + call(self.user, Model, Role.Contributor.value), + ]) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_update_model_version_guest(self, filter_rights_mock): """ Cannot update a worker run with a model_version when you only have guest access to the model, and the model version has no tag or is not available @@ -1555,6 +1576,17 @@ class TestWorkerRuns(FixtureAPITestCase): parents=[], ) + def filter_rights(user, model, level): + """ + The filter_rights mock needs to return nothing when called for contributor access, + and the models we will test on when called for guest access + """ + if level == Role.Guest.value: + return Model.objects.filter(id__in=(self.model_2.id, self.model_3.id)) + return Model.objects.none() + + filter_rights_mock.side_effect = filter_rights + cases = [ # On a model with a membership giving guest access (self.model_version_2, None, ModelVersionState.Created), @@ -1571,12 +1603,13 @@ class TestWorkerRuns(FixtureAPITestCase): ] for model_version, tag, state in cases: + filter_rights_mock.reset_mock() with self.subTest(model_version=model_version, tag=tag, state=state): model_version.tag = tag model_version.state = state model_version.save() - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -1591,6 +1624,11 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{model_version.id}" - object does not exist.'], }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Guest.value), + call(self.user, Model, Role.Contributor.value), + ]) + def test_update_model_version_unavailable(self): self.client.force_login(self.user) rev_2 = self.repo.revisions.create( @@ -1611,7 +1649,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.model_version_1.state = ModelVersionState.Error self.model_version_1.save() - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -1643,7 +1681,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.model_1.archived = datetime.now(timezone.utc) self.model_1.save() - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -1691,7 +1729,7 @@ class TestWorkerRuns(FixtureAPITestCase): ] for model_version in model_versions: with self.subTest(model_version=model_version): - with self.assertNumQueries(13): + with self.assertNumQueries(9): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -1784,7 +1822,7 @@ class TestWorkerRuns(FixtureAPITestCase): # Check generated summary, before updating, there should be only information about the worker version self.assertEqual(run.summary, f"Worker {self.worker_1.name} @ {str(version_with_model.id)[:6]}") - with self.assertNumQueries(14): + with self.assertNumQueries(10): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -1876,7 +1914,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -1957,8 +1995,8 @@ class TestWorkerRuns(FixtureAPITestCase): configuration=None if configuration else self.configuration_1, ) - # Having a model version set adds two queries, having a configuration adds one - query_count = 8 + bool(model_version) * 2 + bool(configuration) + # Having a model version or a configuration adds one query for each + query_count = 5 + bool(model_version) + bool(configuration) with self.assertNumQueries(query_count): response = self.client.put( @@ -1976,16 +2014,18 @@ class TestWorkerRuns(FixtureAPITestCase): "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], }) + @expectedFailure def test_update_agent(self): """ Ponos agents cannot update WorkerRuns, even when they can access them """ self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) - with self.assertNumQueries(3): + # Agent auth is not implemented in CE + self.client.force_login(self.user) + with self.assertNumQueries(4): response = self.client.put( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { @@ -2018,14 +2058,15 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_partial_update_no_project_admin_right(self): + @patch("arkindex.project.mixins.get_max_level", return_value=Role.Contributor.value) + def test_partial_update_no_project_admin_right(self, get_max_level_mock): """ - A user cannot update a worker run if he has no admin access on its process project + A user cannot update a worker run if they have no admin access on its process project """ - self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -2033,6 +2074,9 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertEqual(response.json(), {"detail": "You do not have an admin access to this process."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.corpus)) + def test_partial_update_invalid_id(self): rev_2 = self.repo.revisions.create( hash="2", @@ -2082,7 +2126,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_partial_update_nonexistent_parent(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -2104,7 +2148,7 @@ class TestWorkerRuns(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -2166,7 +2210,7 @@ class TestWorkerRuns(FixtureAPITestCase): """ self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -2227,7 +2271,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertEqual(self.run_1.configuration, None) self.assertEqual(self.run_1.summary, f"Worker {self.run_1.version.worker.name} @ {str(self.run_1.version.id)[:6]}") - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), data={ @@ -2293,7 +2337,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.client.force_login(self.user) self.assertEqual(self.run_1.configuration, None) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), data={"configuration_id": str(self.configuration_2.id)}, @@ -2311,7 +2355,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertTrue(self.process_1.tasks.exists()) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -2346,7 +2390,7 @@ class TestWorkerRuns(FixtureAPITestCase): parents=[], ) - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -2382,7 +2426,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) random_model_version_uuid = str(uuid.uuid4()) - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -2396,7 +2440,8 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] }) - def test_partial_update_model_version_no_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=ModelVersion.objects.none()) + def test_partial_update_model_version_no_access(self, filter_rights_mock): """ Cannot update a worker run with a model_version UUID, when you don't have access to the model version """ @@ -2421,7 +2466,7 @@ class TestWorkerRuns(FixtureAPITestCase): model_no_access = Model.objects.create(name="Secret model") model_version_no_access = ModelVersion.objects.create(model=model_no_access, state=ModelVersionState.Available, size=8, hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -2435,7 +2480,13 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{model_version_no_access.id}" - object does not exist.'], }) - def test_partial_update_model_version_guest(self): + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Guest.value), + call(self.user, Model, Role.Contributor.value), + ]) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_partial_update_model_version_guest(self, filter_rights_mock): """ Cannot update a worker run with a model_version when you only have guest access to the model, and the model version has no tag or is not available @@ -2457,6 +2508,17 @@ class TestWorkerRuns(FixtureAPITestCase): parents=[], ) + def filter_rights(user, model, level): + """ + The filter_rights mock needs to return nothing when called for contributor access, + and the models we will test on when called for guest access + """ + if level == Role.Guest.value: + return Model.objects.filter(id__in=(self.model_2.id, self.model_3.id)) + return Model.objects.none() + + filter_rights_mock.side_effect = filter_rights + cases = [ # On a model with a membership giving guest access (self.model_version_2, None, ModelVersionState.Created), @@ -2473,12 +2535,13 @@ class TestWorkerRuns(FixtureAPITestCase): ] for model_version, tag, state in cases: + filter_rights_mock.reset_mock() with self.subTest(model_version=model_version, tag=tag, state=state): model_version.tag = tag model_version.state = state model_version.save() - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), data={ @@ -2492,6 +2555,11 @@ class TestWorkerRuns(FixtureAPITestCase): "model_version_id": [f'Invalid pk "{model_version.id}" - object does not exist.'], }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Guest.value), + call(self.user, Model, Role.Contributor.value), + ]) + def test_partial_update_model_version_unavailable(self): self.client.force_login(self.user) rev_2 = self.repo.revisions.create( @@ -2512,7 +2580,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.model_version_1.state = ModelVersionState.Error self.model_version_1.save() - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -2544,7 +2612,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.model_1.archived = datetime.now(timezone.utc) self.model_1.save() - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -2591,7 +2659,7 @@ class TestWorkerRuns(FixtureAPITestCase): ] for model_version in model_versions: with self.subTest(model_version=model_version): - with self.assertNumQueries(13): + with self.assertNumQueries(9): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -2682,7 +2750,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.assertEqual(run.model_version_id, None) - with self.assertNumQueries(13): + with self.assertNumQueries(9): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), data={ @@ -2771,7 +2839,7 @@ class TestWorkerRuns(FixtureAPITestCase): ) self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), data={ @@ -2852,8 +2920,8 @@ class TestWorkerRuns(FixtureAPITestCase): configuration=None if configuration else self.configuration_1, ) - # Having a model version set adds two queries, having a configuration adds one - query_count = 8 + bool(model_version) * 2 + bool(configuration) + # Having a model version or a configuration adds one query for each + query_count = 5 + bool(model_version) + bool(configuration) with self.assertNumQueries(query_count): response = self.client.patch( @@ -2871,16 +2939,18 @@ class TestWorkerRuns(FixtureAPITestCase): "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], }) + @expectedFailure def test_partial_update_agent(self): """ Ponos agents cannot update WorkerRuns, even when they can access them """ self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) - with self.assertNumQueries(3): + # Agent auth is not implemented in CE + self.client.force_login(self.user) + with self.assertNumQueries(4): response = self.client.patch( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { @@ -2909,7 +2979,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.worker_1.memberships.update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -2936,7 +3006,7 @@ class TestWorkerRuns(FixtureAPITestCase): def test_delete(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -2979,7 +3049,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertTrue(self.run_1.id in run_3.parents) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -3000,7 +3070,7 @@ class TestWorkerRuns(FixtureAPITestCase): self.process_1.run() self.process_1.tasks.update(state=State.Running) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) ) @@ -3008,16 +3078,18 @@ class TestWorkerRuns(FixtureAPITestCase): self.assertEqual(response.json(), ["WorkerRuns cannot be deleted from a process that has already started."]) + @expectedFailure def test_delete_agent(self): """ Ponos agents cannot delete WorkerRuns, even when they can access them """ self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) - with self.assertNumQueries(2): + # Agent auth is not implemented in CE + self.client.force_login(self.user) + with self.assertNumQueries(3): response = self.client.delete( reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - HTTP_AUTHORIZATION=f"Bearer {self.agent.token.access_token}", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), { diff --git a/arkindex/process/tests/test_workers.py b/arkindex/process/tests/test_workers.py index bae4880741..98a2d31ffd 100644 --- a/arkindex/process/tests/test_workers.py +++ b/arkindex/process/tests/test_workers.py @@ -1,9 +1,11 @@ import uuid from datetime import datetime, timezone +from unittest.mock import call, patch from django.urls import reverse from rest_framework import status +from arkindex.documents.models import Corpus from arkindex.ponos.models import Farm from arkindex.process.models import ( CorpusWorkerVersion, @@ -21,7 +23,7 @@ from arkindex.process.models import ( ) from arkindex.project.tests import FixtureAPITestCase from arkindex.training.models import Model, ModelVersionState -from arkindex.users.models import Right, Role, User +from arkindex.users.models import Role, User class TestWorkersWorkerVersions(FixtureAPITestCase): @@ -88,12 +90,14 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): def test_workers_list(self): """ - User is able to list workers on the repository as he has an admin access + User is able to list workers on the repository as they have an admin access """ self.client.force_login(self.user) - with self.assertNumQueries(7): + + with self.assertNumQueries(4): response = self.client.get(reverse("api:workers-list")) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 6, "next": None, @@ -163,14 +167,15 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): """ self.model.memberships.create(user=self.user, level=Role.Guest.value) self.worker_generic.models.set([self.model]) - self.client.force_login(self.user) - with self.assertNumQueries(8): + + with self.assertNumQueries(5): response = self.client.get( reverse("api:workers-list"), {"compatible_model": str(self.model.id)}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 1, "next": None, @@ -192,14 +197,15 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): def test_workers_list_compatible_model_public(self): public_model = Model.objects.create(name="Public", public=True) self.worker_reco.models.set([public_model]) - self.client.force_login(self.user) - with self.assertNumQueries(9): + + with self.assertNumQueries(5): response = self.client.get( reverse("api:workers-list"), {"compatible_model": str(public_model.id)}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), { "count": 1, "next": None, @@ -216,22 +222,37 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): }], }) - def test_worker_list_compatible_model_private(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_worker_list_compatible_model_private(self, filter_rights_mock): private_model = Model.objects.create(name="Private") self.worker_reco.models.set([private_model]) self.client.force_login(self.user) - with self.assertNumQueries(5): + filter_rights_mock.side_effect = [ + Worker.objects.all(), + Repository.objects.all(), + Model.objects.none(), + ] + + with self.assertNumQueries(2): response = self.client.get( reverse("api:workers-list"), {"compatible_model": str(private_model.id)}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual(response.json(), { "compatible_model": ["This model does not exist or you don't have access to it"] }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + call(self.user, Model, Role.Guest.value), + ]) + def test_workers_list_filter_invalid_query_params(self): self.client.force_login(self.user) + with self.assertNumQueries(2): response = self.client.get( reverse("api:workers-list"), @@ -241,6 +262,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): }, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), { "compatible_model": ["Invalid UUID"], "repository_id": ["Invalid UUID"], @@ -251,9 +273,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): User is able to filter workers on the repository by worker type slug """ self.client.force_login(self.user) - with self.assertNumQueries(8): + + with self.assertNumQueries(5): response = self.client.get(reverse("api:workers-list"), {"type": "dla"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 1, "next": None, @@ -277,7 +301,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_dla.archived = datetime.now(timezone.utc) self.worker_dla.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:workers-list"), {"archived": True}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -305,7 +329,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_dla.archived = None self.worker_dla.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:workers-list"), {"archived": False}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -332,9 +356,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): Raise when trying to filter workers with an invalid type slug """ self.client.force_login(self.user) + with self.assertNumQueries(3): response = self.client.get(reverse("api:workers-list"), {"type": "invalid-slug"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual(response.json(), {"type": ["No registered worker type with that slug."]}) def test_workers_list_filter_type_id(self): @@ -342,9 +368,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): User is able to filter workers on the repository by worker type id """ self.client.force_login(self.user) - with self.assertNumQueries(7): + + with self.assertNumQueries(4): response = self.client.get(reverse("api:workers-list"), {"type": self.worker_type_dla.id}) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 1, "next": None, @@ -363,16 +391,21 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ] }) - def test_workers_list_requires_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_workers_list_requires_contributor(self, filter_rights_mock): """ User is not able to list workers with a guest access """ - self.repo.memberships.filter(user=self.user).update(level=Role.Guest.value) - self.worker_custom.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(4): + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.none(), + ] + + with self.assertNumQueries(3): response = self.client.get(reverse("api:workers-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 0, "next": None, @@ -381,40 +414,18 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "results": [] }) - def test_workers_list_public_attribute(self): - """ - Any authenticated user is able to list a worker with the public attribute - """ - user = User.objects.create_user("user42@test.fr", "abcd") - user.verified_email = True - user.save() - self.worker_reco.public = True - self.worker_reco.save() - self.client.force_login(user) - with self.assertNumQueries(7): - response = self.client.get(reverse("api:workers-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "count": 1, - "next": None, - "number": 1, - "previous": None, - "results": [{ - "id": str(self.worker_reco.id), - "repository_id": str(self.repo.id), - "name": "Recognizer", - "description": "", - "slug": "reco", - "type": "recognizer", - "archived": False, - }] - }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) def test_workers_list_name_filter(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + + with self.assertNumQueries(4): response = self.client.get(reverse("api:workers-list"), {"name": "layout"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 1, "next": None, @@ -444,10 +455,12 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): type=self.worker_type_dla ) worker_2.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - response = self.client.get(reverse("api:workers-list"), {"repository_id": str(repo2.id)}) - self.assertEqual(response.status_code, status.HTTP_200_OK) + + with self.assertNumQueries(4): + response = self.client.get(reverse("api:workers-list"), {"repository_id": str(repo2.id)}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "count": 1, "next": None, @@ -479,11 +492,12 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ) worker_2 = repo2.workers.create(name="Worker 2", slug="worker_2", type=self.worker_type_dla) repo2.memberships.create(user=self.user, level=Role.Contributor.value) - self.client.force_login(self.user) - with self.assertNumQueries(5): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:worker-retrieve", kwargs={"pk": str(worker_2.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "id": str(worker_2.id), "repository_id": str(repo2.id), @@ -496,9 +510,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): def test_workers_retrieve_no_revision(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_custom.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "id": str(self.worker_custom.id), "repository_id": None, @@ -509,46 +525,50 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }) - def test_workers_retrieve_no_rights(self): - new_user = User.objects.create(email="new@test.fr", verified_email=True) - self.client.force_login(new_user) - with self.assertNumQueries(6): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_workers_retrieve_no_rights(self, filter_rights_mock): + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.none(), + ] + self.client.force_login(self.user) + + with self.assertNumQueries(3): response = self.client.get(reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.json(), {"detail": "Not found."}) - def test_workers_retrieve_guest(self): - """ - Users cannot retrieve workers with guest access, as guest has no meaning on workers - """ - self.client.force_login(self.user) - self.worker_custom.memberships.filter(user=self.user).update(level=Role.Guest.value) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_custom.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + def test_worker_create_requires_login(self): with self.assertNumQueries(0): response = self.client.post(reverse("api:workers-list")) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), {"detail": "Authentication credentials were not provided."}) def test_worker_create_requires_verified(self): self.user.verified_email = False self.user.save() self.client.force_login(self.user) + with self.assertNumQueries(2): response = self.client.post(reverse("api:workers-list")) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), {"detail": "You do not have permission to perform this action."}) def test_worker_create_required_fields(self): self.client.force_login(self.user) + with self.assertNumQueries(2): response = self.client.post(reverse("api:workers-list")) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { "name": ["This field is required."], "slug": ["This field is required."], @@ -559,7 +579,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): """ Repository ID is deduced from the authenticated task. """ - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.post( reverse("api:workers-list"), data={ @@ -571,6 +591,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + worker = Worker.objects.get(id=response.json()["id"]) assert worker.repository_id == self.repo.id @@ -590,6 +611,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): } ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data = response.json() worker = Worker.objects.get(id=data["id"]) self.assertEqual(worker.type, self.worker_type_dla) @@ -607,14 +629,16 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }) - def test_worker_create_user_existing_no_execution_access(self): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Guest.value) + def test_worker_create_user_existing_no_execution_access(self, get_max_level_mock): """ - A user cannot retrieve a worker without an execution access in case the slug already exist. + A user cannot retrieve a worker without an execution access in case the slug already exists. No worker type is created. """ self.worker_custom.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(8): + + with self.assertNumQueries(6): response = self.client.post( reverse("api:workers-list"), data={ @@ -624,19 +648,23 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): } ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(WorkerType.objects.filter(slug="new_type").exists()) self.assertQuerysetEqual(self.worker_custom.memberships.values_list("user_id", "level"), [ (self.user.id, Role.Guest.value) ]) self.assertDictEqual(response.json(), {"slug": ["You cannot use this value."]}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.worker_custom)) def test_worker_create_task_repository_process(self): """ - Ponos task can create a worker only on a process of Repository type + Ponos tasks can create a worker only on a process of Repository type """ process = Process.objects.get(mode=ProcessMode.Workers) process.run() task = process.tasks.first() + with self.assertNumQueries(1): response = self.client.post( reverse("api:workers-list"), @@ -648,6 +676,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): HTTP_AUTHORIZATION=f"Ponos {task.token}", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), { "detail": ( "Workers can only be created on processes of type Repository with a" @@ -655,15 +684,12 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ) }) - def test_worker_create_task_rights(self): + @patch("arkindex.process.serializers.workers.get_max_level", return_value=None) + def test_worker_create_task_rights(self, get_max_level_mock): """ Ponos tasks are not allowed to create workers when they do not have a right on the repository """ - Right.objects.filter( - content_id__in=(self.repo.id, *self.repo.workers.values_list("id", flat=True)), - user=self.user - ).delete() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:workers-list"), data={ @@ -676,11 +702,14 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.repo)) + def test_worker_create_task_return_existing_worker(self): """ Creation with an existing slug returns the existing worker with a HTTP status 200 """ - with self.assertNumQueries(15): + with self.assertNumQueries(8): response = self.client.post( reverse("api:workers-list"), data={ @@ -692,6 +721,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() self.assertEqual(data["id"], str(self.worker_reco.id)) self.assertEqual(data["name"], "Recognizer") @@ -704,7 +734,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): """ new_worker_type_slug = "newType" self.assertFalse(WorkerType.objects.filter(slug=new_worker_type_slug).exists()) - with self.assertNumQueries(14): + + with self.assertNumQueries(11): response = self.client.post( reverse("api:workers-list"), data={ @@ -715,6 +746,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data = response.json() self.assertTrue(WorkerType.objects.filter(slug=new_worker_type_slug).exists()) self.assertNotEqual(data["id"], str(self.worker_reco.id)) @@ -734,8 +766,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "Authentication credentials were not provided."}) def test_update_requires_verified(self): @@ -754,16 +786,21 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_update_requires_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_update_requires_contributor(self, filter_rights_mock): self.worker_reco.memberships.update_or_create(user=self.user, defaults={"level": Role.Guest.value}) self.repo.memberships.update_or_create(user=self.user, defaults={"level": Role.Guest.value}) self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.none(), + ] - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -774,16 +811,23 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) - def test_update(self): - self.repo.memberships.filter(user=self.user).delete() - self.worker_reco.memberships.update_or_create(user=self.user, defaults={"level": Role.Contributor.value}) + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_update(self, filter_rights_mock): self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.all(), + Repository.objects.none(), + ] - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -794,8 +838,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), { "id": str(self.worker_reco.id), "name": "New name", @@ -813,12 +857,20 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(self.worker_reco.type, self.worker_type_dla) self.assertEqual(self.worker_reco.repository_id, self.repo.id) - def test_update_repository_contributor(self): - self.worker_reco.memberships.filter(user=self.user).delete() - self.repo.memberships.update_or_create(user=self.user, defaults={"level": Role.Contributor.value}) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_update_repository_contributor(self, filter_rights_mock): self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.all(), + ] - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -829,8 +881,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), { "id": str(self.worker_reco.id), "name": "New name", @@ -848,10 +900,15 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(self.worker_reco.type, self.worker_type_dla) self.assertEqual(self.worker_reco.repository_id, self.repo.id) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + def test_update_unique_slug(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -862,13 +919,14 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "archived": False, }, ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), { "non_field_errors": ["A worker with the same repository and slug already exists."], }) - def test_update_archived_requires_archivable(self): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_update_archived_requires_archivable(self, get_max_level_mock): self.client.force_login(self.user) self.assertFalse(self.worker_reco.is_archivable(self.user)) @@ -879,10 +937,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): for current_value, new_value in cases: with self.subTest(current_value=current_value, new_value=new_value): + get_max_level_mock.reset_mock() self.worker_reco.archived = current_value self.worker_reco.save() - with self.assertNumQueries(10): + with self.assertNumQueries(4): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -898,6 +957,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(response.json(), { "archived": ["You are not allowed to archive or unarchive this worker."], }) + self.assertEqual(get_max_level_mock.call_count, 2) + self.assertListEqual(get_max_level_mock.call_args_list, [call(self.user, self.worker_reco)] * 2) def test_update_archived(self): self.client.force_login(self.user) @@ -914,7 +975,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_reco.archived = current_value self.worker_reco.save() - with self.assertNumQueries(11): + with self.assertNumQueries(6): response = self.client.put( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -945,8 +1006,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "description": "New description", }, ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "Authentication credentials were not provided."}) def test_partial_update_requires_verified(self): @@ -961,32 +1022,47 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "description": "New description", }, ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_partial_update_requires_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_partial_update_requires_contributor(self, filter_rights_mock): self.worker_reco.memberships.update_or_create(user=self.user, defaults={"level": Role.Guest.value}) self.repo.memberships.update_or_create(user=self.user, defaults={"level": Role.Guest.value}) self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.none(), + ] - with self.assertNumQueries(4): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { "description": "New description", }, ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.json(), {"detail": "Not found."}) - def test_partial_update(self): + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_partial_update(self, filter_rights_mock): self.repo.memberships.filter(user=self.user).delete() self.worker_reco.memberships.update_or_create(user=self.user, defaults={"level": Role.Contributor.value}) self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.all(), + Repository.objects.none(), + ] - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -994,8 +1070,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "type": "dla", }, ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), { "id": str(self.worker_reco.id), "name": "Recognizer", @@ -1013,12 +1089,22 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(self.worker_reco.type, self.worker_type_dla) self.assertEqual(self.worker_reco.repository_id, self.repo.id) - def test_partial_update_repository_contributor(self): + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_partial_update_repository_contributor(self, filter_rights_mock): self.worker_reco.memberships.filter(user=self.user).delete() self.repo.memberships.update_or_create(user=self.user, defaults={"level": Role.Contributor.value}) self.client.force_login(self.user) + filter_rights_mock.side_effect = [ + Worker.objects.none(), + Repository.objects.all(), + ] - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -1026,8 +1112,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "slug": "new_slug", }, ) + self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), { "id": str(self.worker_reco.id), "name": "New name", @@ -1045,10 +1131,15 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(self.worker_reco.type.slug, "recognizer") self.assertEqual(self.worker_reco.repository_id, self.repo.id) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + def test_partial_update_unique_slug(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -1061,7 +1152,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "non_field_errors": ["A worker with the same repository and slug already exists."], }) - def test_partial_update_archived_requires_archivable(self): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_partial_update_archived_requires_archivable(self, get_max_level_mock): self.client.force_login(self.user) self.assertFalse(self.worker_reco.is_archivable(self.user)) @@ -1072,10 +1164,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): for current_value, new_value in cases: with self.subTest(current_value=current_value, new_value=new_value): + get_max_level_mock.reset_mock() self.worker_reco.archived = current_value self.worker_reco.save() - with self.assertNumQueries(9): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -1087,6 +1180,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.assertEqual(response.json(), { "archived": ["You are not allowed to archive or unarchive this worker."], }) + self.assertEqual(get_max_level_mock.call_count, 2) + self.assertListEqual(get_max_level_mock.call_args_list, [call(self.user, self.worker_reco)] * 2) def test_partial_update_archived(self): self.client.force_login(self.user) @@ -1103,7 +1198,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_reco.archived = current_value self.worker_reco.save() - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:worker-retrieve", kwargs={"pk": str(self.worker_reco.id)}), { @@ -1126,7 +1221,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): def test_versions_list_requires_login(self): with self.assertNumQueries(0): response = self.client.get(reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_versions_list(self): self.client.force_login(self.user) @@ -1139,9 +1234,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ) last_version.created = "1999-09-09T09:09:09.090909Z" last_version.save() - with self.assertNumQueries(14): + + with self.assertNumQueries(9): response = self.client.get(reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() self.assertEqual(data["count"], 2) # The revision created in this test has a recent timestamp so it is displayed first @@ -1168,23 +1265,25 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): "created": "1999-09-09T09:09:09.090909Z", }) - def test_workers_versions_list_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_versions_list_requires_contributor(self, has_access_mock): """ User is not able to list worker versions with a guest access """ - self.repo.memberships.filter(user=self.user).update(level=Role.Guest.value) - self.worker_custom.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(4): - response = self.client.get(reverse("api:workers-list")) - self.assertEqual(response.status_code, status.HTTP_200_OK) + + with self.assertNumQueries(3): + response = self.client.get(reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), { - "count": 0, - "next": None, - "number": 1, - "previous": None, - "results": [] + "detail": "You do not have an execution access to this worker.", }) + self.assertEqual(has_access_mock.call_count, 2) + self.assertEqual(has_access_mock.call_args_list, [ + call(self.user, self.worker_reco, Role.Contributor.value, skip_public=False), + call(self.user, self.repo, Role.Contributor.value, skip_public=False), + ]) def test_versions_list_public_worker(self): """ @@ -1196,9 +1295,11 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_reco.public = True self.worker_reco.save() self.client.force_login(user) + with self.assertNumQueries(9): response = self.client.get(reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() self.assertEqual(data["count"], 1) self.assertEqual(data["results"][0]["id"], str(self.worker_reco.versions.first().id)) @@ -1218,9 +1319,10 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): gpu_usage=FeatureUsage.Disabled ) - with self.assertNumQueries(14): + with self.assertNumQueries(9): response = self.client.get(reverse("api:worker-versions", kwargs={"pk": str(worker_2.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() self.assertEqual(len(data["results"]), 1) version = data["results"][0] @@ -1255,29 +1357,29 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): ]) # Complete mode - with self.assertNumQueries(13): + with self.subTest(mode="complete"), self.assertNumQueries(8): response = self.client.get( reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)}), {"mode": "complete"}, HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["count"], 6) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 6) # Simple mode - with self.assertNumQueries(10): + with self.subTest(mode="simple"), self.assertNumQueries(8): response = self.client.get( reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)}), {"mode": "simple"}, HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.json() - self.assertEqual(data["count"], 3) - self.assertCountEqual( - [version["revision"]["id"] for version in data["results"]], - [str(tagged_rev.id), str(master_rev.id), str(main_rev.id)] - ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["count"], 3) + self.assertCountEqual( + [version["revision"]["id"] for version in data["results"]], + [str(tagged_rev.id), str(master_rev.id), str(main_rev.id)] + ) def test_create_version_non_existing_worker(self): with self.assertNumQueries(2): @@ -1385,7 +1487,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_custom.memberships.filter(user=self.user).update(level=Role.Admin.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_custom.id)}), data={"revision_id": str(self.rev2.id), "configuration": {"test": "test2"}, "model_usage": FeatureUsage.Required.value}, @@ -1401,7 +1503,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_custom.memberships.filter(user=self.user).update(level=Role.Admin.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)}), data={"configuration": {"test": "test2"}, "model_usage": FeatureUsage.Required.value}, @@ -1417,7 +1519,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_custom.memberships.filter(user=self.user).update(level=Role.Admin.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_custom.id)}), data={"revision_id": str(self.rev2.id), "configuration": {"test": "test2"}, "model_usage": FeatureUsage.Required.value}, @@ -1434,6 +1536,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): Ponos Task auth cannot create a version on a worker that is not linked to a repository. """ self.worker_custom.versions.create(version=41, configuration={}) + with self.assertNumQueries(4): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_custom.id)}), @@ -1442,24 +1545,31 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): HTTP_AUTHORIZATION=f"Ponos {self.task.token}", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual(response.json(), { "revision_id": ["Task authentication requires to create a version on workers linked to a repository."] }) - def test_create_version_user_auth_requires_admin(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_version_user_auth_requires_admin(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_custom.id)}), data={"configuration": {"test": "val"}, "model_usage": FeatureUsage.Required.value}, format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have an admin access to this worker."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.worker_custom, Role.Admin.value, skip_public=False)) + def test_create_version_user_auth_requires_null_repository(self): self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_dla.id)}), data={"revision_id": str(self.rev2.id), "configuration": {"test": "test2"}, "model_usage": FeatureUsage.Required.value}, @@ -1478,7 +1588,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.worker_custom.memberships.filter(user=self.user).update(level=Role.Admin.value) self.worker_custom.versions.create(version=41, configuration={}) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_custom.id)}), data={"configuration": {"test": "val"}, "model_usage": FeatureUsage.Required.value}, @@ -1512,7 +1622,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): Configuration body must be an object """ self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(3): response = self.client.post( reverse("api:worker-versions", kwargs={"pk": str(self.worker_reco.id)}), data={"configuration": "test"}, @@ -2483,7 +2593,8 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): }] }) - def test_corpus_worker_version_no_login_private(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.filter(public=True)) + def test_corpus_worker_version_no_login_private(self, filter_rights_mock): self.corpus.public = False self.corpus.save() self.corpus.worker_versions.set([self.version_1, self.version_2]) @@ -2502,7 +2613,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): corpus_worker_version_1 = self.corpus.worker_version_cache.create(worker_version=self.version_1) corpus_worker_version_2 = self.corpus.worker_version_cache.create(worker_version=self.version_2) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get(reverse("api:corpus-versions", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -2594,7 +2705,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): worker_configuration=conf_2, ) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get(reverse("api:corpus-versions", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -2703,7 +2814,7 @@ class TestWorkersWorkerVersions(FixtureAPITestCase): self.client.force_login(self.user) corpus_worker_version = self.corpus.worker_version_cache.create(worker_version=self.version_1) - with self.assertNumQueries(8): + with self.assertNumQueries(6): response = self.client.get(reverse("api:corpus-versions", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/arkindex/process/utils.py b/arkindex/process/utils.py index 6bfc0e65a6..6928d65b8c 100644 --- a/arkindex/process/utils.py +++ b/arkindex/process/utils.py @@ -1,7 +1,6 @@ import json from hashlib import md5 -from django.conf import settings from django.db.models import CharField, Value from django.db.models.functions import Cast, Concat, NullIf @@ -11,24 +10,7 @@ __default_farm = None def get_default_farm(): - """ - Return the default Ponos Farm. - If defined, use PONOS_DEFAULT_FARM setting. - Otherwise, pick the first existing farm or create it. - - This method uses a global variable to store the cached value retrieved in a idempotent way. - It could return an invalid farm in case the default farm is removed or updated during runtime. - """ - from arkindex.ponos.models import Farm - - global __default_farm - if __default_farm is not None: - return __default_farm - if settings.PONOS_DEFAULT_FARM: - __default_farm = Farm.objects.get(pk=settings.PONOS_DEFAULT_FARM) - else: - __default_farm = Farm.objects.order_by("name").first() or Farm.objects.create(name="Default farm") - return __default_farm + return None def hash_object(object): diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index 616d44640b..17c92d941b 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -59,22 +59,7 @@ from arkindex.documents.api.ml import ( ) from arkindex.documents.api.search import CorpusSearch, SearchIndexBuild from arkindex.images.api import IIIFInformationCreate, IIIFURLCreate, ImageCreate, ImageElements, ImageRetrieve -from arkindex.ponos.api import ( - AgentActions, - AgentDetails, - AgentRegister, - AgentsState, - AgentTokenRefresh, - FarmList, - PublicKeyEndpoint, - SecretDetails, - TaskArtifactDownload, - TaskArtifacts, - TaskCreate, - TaskDefinition, - TaskDetailsFromAgent, - TaskUpdate, -) +from arkindex.ponos.api import TaskArtifactDownload, TaskArtifacts, TaskDetailsFromAgent, TaskUpdate from arkindex.process.api import ( ApplyProcessTemplate, BucketList, @@ -136,19 +121,13 @@ from arkindex.training.api import ( ValidateModelVersion, ) from arkindex.users.api import ( - GenericMembershipCreate, - GenericMembershipsList, - GroupDetails, - GroupsCreate, JobList, JobRetrieve, - MembershipDetails, PasswordReset, PasswordResetConfirm, UserCreate, UserEmailLogin, UserEmailVerification, - UserMemberships, UserRetrieve, ) @@ -326,14 +305,6 @@ api = [ path("user/password-reset/", PasswordReset.as_view(), name="password-reset"), path("user/password-reset/confirm/", PasswordResetConfirm.as_view(), name="password-reset-confirm"), - # Rights management - path("groups/", GroupsCreate.as_view(), name="groups-create"), - path("group/<uuid:pk>/", GroupDetails.as_view(), name="group-details"), - path("user/memberships/", UserMemberships.as_view(), name="user-memberships"), - path("membership/<uuid:pk>/", MembershipDetails.as_view(), name="membership-details"), - path("memberships/", GenericMembershipsList.as_view(), name="memberships-list"), - path("memberships/create/", GenericMembershipCreate.as_view(), name="membership-create"), - # Asynchronous jobs path("jobs/", JobList.as_view(), name="jobs-list"), path("jobs/<path:pk>/", JobRetrieve.as_view(), name="jobs-retrieve"), @@ -342,18 +313,12 @@ api = [ path("openapi/", OpenApiSchemaView.as_view(), name="openapi-schema"), # Ponos - path("task/", TaskCreate.as_view(), name="task-create"), path("task/<uuid:pk>/", TaskUpdate.as_view(), name="task-update"), path( "task/<uuid:pk>/from-agent/", TaskDetailsFromAgent.as_view(), name="task-details", ), - path( - "task/<uuid:pk>/definition/", - TaskDefinition.as_view(), - name="task-definition", - ), path( "task/<uuid:pk>/artifacts/", TaskArtifacts.as_view(), name="task-artifacts" ), @@ -362,12 +327,4 @@ api = [ TaskArtifactDownload.as_view(), name="task-artifact-download", ), - path("agent/", AgentRegister.as_view(), name="agent-register"), - path("agent/<uuid:pk>/", AgentDetails.as_view(), name="agent-details"), - path("agent/refresh/", AgentTokenRefresh.as_view(), name="agent-token-refresh"), - path("agent/actions/", AgentActions.as_view(), name="agent-actions"), - path("agents/", AgentsState.as_view(), name="agents-state"), - path("public-key/", PublicKeyEndpoint.as_view(), name="public-key"), - path("secret/<path:name>", SecretDetails.as_view(), name="secret-details"), - path("farms/", FarmList.as_view(), name="farm-list"), ] diff --git a/arkindex/project/checks.py b/arkindex/project/checks.py index f77237a4d9..4e9845fcb7 100644 --- a/arkindex/project/checks.py +++ b/arkindex/project/checks.py @@ -78,23 +78,6 @@ def local_imageserver_check(*args, **kwargs): return [] -@register() -@only_runserver -def ponos_key_check(*args, **kwargs): - """ - Warn about a missing Ponos private key that would prevent any Ponos agent from authenticating - """ - from django.conf import settings - if not os.path.exists(settings.PONOS_PRIVATE_KEY): - return [Warning( - f"Ponos private key at {settings.PONOS_PRIVATE_KEY} not found. " - "Agents will be unable to connect to this server.", - hint=f"`ponos.private_key` in {settings.CONFIG_PATH}", - id="arkindex.W007", - )] - return [] - - @register() def ponos_env_check(*args, **kwargs): """ diff --git a/arkindex/project/config.py b/arkindex/project/config.py index c57204f570..8e373583ac 100644 --- a/arkindex/project/config.py +++ b/arkindex/project/config.py @@ -7,7 +7,6 @@ updating the configuration documentation on the wiki: """ import uuid from enum import Enum -from pathlib import Path from typing import Optional from urllib.parse import urlparse @@ -154,6 +153,8 @@ def get_settings_parser(base_dir): job_timeouts_parser.add_option("process_delete", type=int, default=3600) job_timeouts_parser.add_option("reindex_corpus", type=int, default=7200) job_timeouts_parser.add_option("notify_process_completion", type=int, default=120) + # Task execution in RQ timeouts after 10 hours by default + job_timeouts_parser.add_option("task", type=int, default=36000) csrf_parser = parser.add_subparser("csrf", default={}) csrf_parser.add_option("cookie_name", type=str, default="arkindex.csrf") @@ -177,9 +178,7 @@ def get_settings_parser(base_dir): ponos_parser = parser.add_subparser("ponos", default={}) # Do not use file_path here to allow the backend to start without a Ponos key - ponos_parser.add_option("private_key", type=Path, default=(base_dir / "ponos.key").resolve()) ponos_parser.add_option("default_env", type=dict, default={}) - ponos_parser.add_option("default_farm", type=uuid.UUID, default=None) ponos_parser.add_option("artifact_max_size", type=int, default=5 * 1024**3) docker_parser = parser.add_subparser("docker", default={}) diff --git a/arkindex/project/mixins.py b/arkindex/project/mixins.py index 6276b450d8..b26f626dd6 100644 --- a/arkindex/project/mixins.py +++ b/arkindex/project/mixins.py @@ -11,7 +11,7 @@ from arkindex.documents.models import Corpus from arkindex.process.models import Process, ProcessMode, Repository, Worker from arkindex.training.models import Model from arkindex.users.models import Role -from arkindex.users.utils import check_level_param, filter_rights, get_max_level +from arkindex.users.utils import filter_rights, get_max_level, has_access class ACLMixin(object): @@ -29,33 +29,7 @@ class ACLMixin(object): return self._user or self.request.user def has_access(self, instance, level, skip_public=False): - """ - Check if the user has access to a generic instance with a minimum level - If skip_public parameter is set to true, exclude rights on public instances - """ - check_level_param(level) - - # Handle special authentications - if not skip_public and level <= Role.Guest.value and getattr(instance, "public", False): - return True - if self.user.is_anonymous: - return False - elif self.user.is_admin: - return True - - return instance.memberships.filter( - Q( - # Right directly owned by this user - Q(user=self.user) - & Q(level__gte=level) - ) - | Q( - # Right owned by the group and by the user - Q(group__memberships__user=self.user) - & Q(level__gte=level) - & Q(group__memberships__level__gte=level) - ) - ).exists() + return has_access(self.user, instance, level, skip_public=skip_public) class RepositoryACLMixin(ACLMixin): diff --git a/arkindex/project/rq_overrides.py b/arkindex/project/rq_overrides.py index 583f1f86cd..ae53b22aed 100644 --- a/arkindex/project/rq_overrides.py +++ b/arkindex/project/rq_overrides.py @@ -2,10 +2,10 @@ from typing import Optional from django_rq import get_connection from django_rq.queues import DjangoRQ -from rq.compat import as_text, decode_redis_hash from rq.exceptions import NoSuchJobError from rq.job import Job as BaseJob from rq.registry import BaseRegistry +from rq.utils import as_text, decode_redis_hash def as_int(value) -> Optional[int]: diff --git a/arkindex/project/serializer_fields.py b/arkindex/project/serializer_fields.py index 40e7830fb4..cefaaf14ec 100644 --- a/arkindex/project/serializer_fields.py +++ b/arkindex/project/serializer_fields.py @@ -291,3 +291,14 @@ class DatasetSetsCountField(serializers.DictField): .values_list("set", "count") ) return elts_count + + +class NullField(serializers.CharField): + + def to_representation(self, value): + return None + + def to_internal_value(self, data): + if data is not None: + self.fail("invalid") + return None diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py index 0932027fe0..afa3b2206a 100644 --- a/arkindex/project/settings.py +++ b/arkindex/project/settings.py @@ -203,7 +203,6 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", - "arkindex.ponos.authentication.AgentAuthentication", "arkindex.ponos.authentication.TaskAuthentication", ), "DEFAULT_PAGINATION_CLASS": "arkindex.project.pagination.PageNumberPagination", @@ -338,21 +337,17 @@ elif conf["cache"]["type"] == CacheType.Dummy: } } +_rq_queue_conf = { + "HOST": conf["redis"]["host"], + "PORT": conf["redis"]["port"], + "DB": conf["redis"]["db"], + "PASSWORD": conf["redis"]["password"], + "DEFAULT_TIMEOUT": conf["redis"]["timeout"], +} RQ_QUEUES = { - "default": { - "HOST": conf["redis"]["host"], - "PORT": conf["redis"]["port"], - "DB": conf["redis"]["db"], - "PASSWORD": conf["redis"]["password"], - "DEFAULT_TIMEOUT": conf["redis"]["timeout"], - }, - "high": { - "HOST": conf["redis"]["host"], - "PORT": conf["redis"]["port"], - "DB": conf["redis"]["db"], - "PASSWORD": conf["redis"]["password"], - "DEFAULT_TIMEOUT": conf["redis"]["timeout"], - } + "default": _rq_queue_conf, + "high": _rq_queue_conf, + "tasks": _rq_queue_conf, } RQ_TIMEOUTS = conf["job_timeouts"] @@ -501,9 +496,10 @@ if DEBUG: }) _ponos_env.update(conf["ponos"]["default_env"]) PONOS_DEFAULT_ENV = _ponos_env -PONOS_PRIVATE_KEY = conf["ponos"]["private_key"] -PONOS_DEFAULT_FARM = conf["ponos"]["default_farm"] PONOS_ARTIFACT_MAX_SIZE = conf["ponos"]["artifact_max_size"] +PONOS_RQ_EXECUTION = True +# Base data directory for RQ tasks execution (in the docker container) +PONOS_DATA_DIR = "/data" # Docker images used by our ponos workflow ARKINDEX_TASKS_IMAGE = conf["docker"]["tasks_image"] @@ -581,8 +577,6 @@ if TEST_ENV: AWS_ACCESS_KEY = "test" AWS_SECRET_KEY = "test" AWS_ENDPOINT = "http://s3" - PONOS_PRIVATE_KEY = None - PONOS_DEFAULT_FARM = None PONOS_S3_ARTIFACTS_BUCKET = "ponos-artifacts" PONOS_S3_LOGS_BUCKET = "ponos-logs" LOCAL_IMAGESERVER_ID = 1 @@ -596,6 +590,9 @@ if TEST_ENV: warnings.filterwarnings("error", category=RuntimeWarning, module="django.core.paginator") warnings.filterwarnings("error", category=RuntimeWarning, module="rest_framework.pagination") + # Disable RQ tasks scheduler during tests + PONOS_RQ_EXECUTION = False + # Optional unit tests runner with code coverage try: import xmlrunner # noqa diff --git a/arkindex/project/tests/config_samples/defaults.yaml b/arkindex/project/tests/config_samples/defaults.yaml index 8945e3e4b9..bd19a0a3ef 100644 --- a/arkindex/project/tests/config_samples/defaults.yaml +++ b/arkindex/project/tests/config_samples/defaults.yaml @@ -61,6 +61,7 @@ job_timeouts: notify_process_completion: 120 process_delete: 3600 reindex_corpus: 7200 + task: 36000 worker_results_delete: 3600 jwt_signing_key: null local_imageserver_id: 1 @@ -68,8 +69,6 @@ metrics_port: 3000 ponos: artifact_max_size: 5368709120 default_env: {} - default_farm: null - private_key: /somewhere/backend/arkindex/ponos.key public_hostname: https://default.config.arkindex.localhost redis: db: 0 diff --git a/arkindex/project/tests/config_samples/errors.yaml b/arkindex/project/tests/config_samples/errors.yaml index ef6bb2392f..351af98d06 100644 --- a/arkindex/project/tests/config_samples/errors.yaml +++ b/arkindex/project/tests/config_samples/errors.yaml @@ -43,6 +43,7 @@ job_timeouts: move_element: a: b reindex_corpus: {} + task: '' worker_results_delete: null jwt_signing_key: null local_imageserver_id: 1 diff --git a/arkindex/project/tests/config_samples/expected_errors.yaml b/arkindex/project/tests/config_samples/expected_errors.yaml index 8845f9c64c..0c4f96b0b2 100644 --- a/arkindex/project/tests/config_samples/expected_errors.yaml +++ b/arkindex/project/tests/config_samples/expected_errors.yaml @@ -24,6 +24,7 @@ job_timeouts: export_corpus: "int() argument must be a string, a bytes-like object or a real number, not 'list'" move_element: "int() argument must be a string, a bytes-like object or a real number, not 'dict'" reindex_corpus: "int() argument must be a string, a bytes-like object or a real number, not 'dict'" + task: "invalid literal for int() with base 10: ''" worker_results_delete: "int() argument must be a string, a bytes-like object or a real number, not 'NoneType'" ponos: artifact_max_size: cannot convert float NaN to integer diff --git a/arkindex/project/tests/config_samples/override.yaml b/arkindex/project/tests/config_samples/override.yaml index 3c8b35618c..3e4f69e593 100644 --- a/arkindex/project/tests/config_samples/override.yaml +++ b/arkindex/project/tests/config_samples/override.yaml @@ -75,7 +75,8 @@ job_timeouts: notify_process_completion: 6 process_delete: 7 reindex_corpus: 8 - worker_results_delete: 9 + task: 9 + worker_results_delete: 10 jwt_signing_key: deadbeef local_imageserver_id: 45 metrics_port: 4242 @@ -83,8 +84,6 @@ ponos: artifact_max_size: 12345678901234567890 default_env: A: B - default_farm: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa - private_key: /a/b/c public_hostname: https://darkindex.lol redis: db: 42 diff --git a/arkindex/project/tests/openapi/test_schema.py b/arkindex/project/tests/openapi/test_schema.py index c8404b812f..e0eb76f5c9 100644 --- a/arkindex/project/tests/openapi/test_schema.py +++ b/arkindex/project/tests/openapi/test_schema.py @@ -55,7 +55,6 @@ class TestAutoSchema(TestCase): "security": [ {"cookieAuth": []}, {"tokenAuth": []}, - {"agentAuth": []}, {"taskAuth": []}, # Allows no authentication too {}, diff --git a/arkindex/project/tests/test_acl_mixin.py b/arkindex/project/tests/test_acl_mixin.py index 60856b17d7..a36975c7b2 100644 --- a/arkindex/project/tests/test_acl_mixin.py +++ b/arkindex/project/tests/test_acl_mixin.py @@ -1,18 +1,12 @@ import uuid +from unittest import expectedFailure from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from arkindex.documents.models import Corpus from arkindex.process.models import Process, ProcessMode, Repository, Revision, WorkerType -from arkindex.project.mixins import ( - ACLMixin, - CorpusACLMixin, - ProcessACLMixin, - RepositoryACLMixin, - TrainingModelMixin, - WorkerACLMixin, -) +from arkindex.project.mixins import ACLMixin, CorpusACLMixin, ProcessACLMixin, TrainingModelMixin from arkindex.project.tests import FixtureTestCase from arkindex.training.models import Model from arkindex.users.models import Group, Right, Role, User @@ -79,6 +73,7 @@ class TestACLMixin(FixtureTestCase): cls.corpus_type = ContentType.objects.get_for_model(Corpus) cls.group_type = ContentType.objects.get_for_model(Group) + @expectedFailure def test_filter_right(self): # List all the rights with a privilege greater than 80 for user 3 params = { @@ -93,6 +88,7 @@ class TestACLMixin(FixtureTestCase): corpora = list(filter_rights(self.user3, Corpus, 80)) self.assertCountEqual(corpora, [self.corpus1]) + @expectedFailure def test_filter_right_including_public(self): # Listing readable objects involve listing public objects with a SQL Union params = { @@ -109,6 +105,7 @@ class TestACLMixin(FixtureTestCase): [self.corpus1, self.corpus] ) + @expectedFailure def test_right_direct_access(self): # User 2 has a direct access to the above corpus with a level of 90 acl_mixin = ACLMixin(user=self.user2) @@ -124,6 +121,7 @@ class TestACLMixin(FixtureTestCase): has_access = acl_mixin.has_access(self.corpus1, 80) self.assertTrue(has_access) + @expectedFailure def test_right_group_members_restriction(self): # User rights on corpora via a group are restricted to user level inside the group acl_mixin = ACLMixin(user=self.user1) @@ -133,7 +131,7 @@ class TestACLMixin(FixtureTestCase): def test_corpus_acl_mixin_has_read_access(self): corpus_acl_mixin = CorpusACLMixin(user=self.user2) - with self.assertNumQueries(3): + with self.assertNumQueries(0): read_access = corpus_acl_mixin.has_read_access(self.corpus1) self.assertTrue(read_access) @@ -147,10 +145,11 @@ class TestACLMixin(FixtureTestCase): def test_corpus_acl_mixin_has_write_access(self): # User2 has a direct access to Corpus1 with an adequate level corpus_acl_mixin = CorpusACLMixin(user=self.user2) - with self.assertNumQueries(3): + with self.assertNumQueries(0): write_access = corpus_acl_mixin.has_write_access(self.corpus1) self.assertTrue(write_access) + @expectedFailure def test_corpus_acl_mixin_has_admin_access(self): # Admin access requires either to have an admin right or to be a django admin/internal user admin_access = [ @@ -167,84 +166,20 @@ class TestACLMixin(FixtureTestCase): self.assertEqual(admin_access, access_check) ContentType.objects.clear_cache() + @expectedFailure def test_corpus_acl_mixin_writable(self): corpus_acl_mixin = CorpusACLMixin(user=self.user1) - with self.assertNumQueries(3): + with self.assertNumQueries(1): corpora = list(corpus_acl_mixin.writable_corpora) self.assertCountEqual( list(corpora), [self.corpus1] ) - def test_repo_acl_mixin_has_read_access(self): - repo_acl_mixin = RepositoryACLMixin(user=self.user2) - with self.assertNumQueries(3): - read_access = repo_acl_mixin.has_read_access(self.repo1) - self.assertTrue(read_access) - - def test_repo_acl_mixin_has_write_access(self): - repo_acl_mixin = RepositoryACLMixin(user=self.user2) - with self.assertNumQueries(3): - exec_access = repo_acl_mixin.has_execution_access(self.repo1) - self.assertFalse(exec_access) - - def test_repo_acl_mixin_has_admin_access(self): - # Admin access requires either to have an admin right or to be a django admin/internal user - admin_access = [ - (self.user, 3, False), - (self.superuser, 0, True), - (self.user1, 3, False), - (self.user2, 3, False), - (self.user3, 3, False) - ] - for user, queries, access_check in admin_access: - repo_acl_mixin = RepositoryACLMixin(user=user) - with self.assertNumQueries(queries): - admin_access = repo_acl_mixin.has_admin_access(self.repo1) - self.assertEqual(admin_access, access_check) - ContentType.objects.clear_cache() - - def test_repo_acl_mixin_readable(self): - repo_acl_mixin = RepositoryACLMixin(user=self.user2) - with self.assertNumQueries(3): - repos = list(repo_acl_mixin.readable_repositories) - self.assertCountEqual( - list(repos), - [self.repo1] - ) - - def test_repo_acl_mixin_executable(self): - repo_acl_mixin = RepositoryACLMixin(user=self.user2) - with self.assertNumQueries(3): - repos = list(repo_acl_mixin.executable_repositories) - self.assertEqual(repos, []) - - def test_worker_acl_mixin_has_worker_access(self): - # Worker access may be determined via its repository - - group1_admin = User.objects.create_user("group1+admin@test.test") - self.group1.memberships.create(user=group1_admin, level=Role.Admin.value) - access_cases = [ - (self.superuser, 100, 0, True), - (self.user1, 100, 3, True), - (self.user, 10, 5, False), - (self.user2, 80, 5, False), - (self.user3, 10, 5, False), - (self.user2, 10, 5, True), - (group1_admin, 100, 5, False), - (group1_admin, 80, 5, True), - ] - for user, level, queries, access_check in access_cases: - worker_acl_mixin = WorkerACLMixin(user=user) - with self.assertNumQueries(queries): - access = worker_acl_mixin.has_worker_access(self.worker, level) - self.assertEqual(access, access_check) - ContentType.objects.clear_cache() - def test_corpus_readable_orderable(self): # Assert corpora retrieved via the mixin are still orderable corpus_acl_mixin = CorpusACLMixin(user=self.user3) - with self.assertNumQueries(3): + with self.assertNumQueries(1): corpora = list(corpus_acl_mixin.readable_corpora.order_by("name")) self.assertListEqual( [c.name for c in corpora], @@ -261,6 +196,7 @@ class TestACLMixin(FixtureTestCase): list(Corpus.objects.all()) ) + @expectedFailure def test_anonymous_user_readable_corpora(self): # An anonymous user should have guest access to any public corpora corpus_acl_mixin = CorpusACLMixin(user=AnonymousUser()) @@ -275,33 +211,39 @@ class TestACLMixin(FixtureTestCase): # User specific rights should be returned instead of the the defaults access for public rights Right.objects.create(user=self.user3, content_object=self.corpus, level=42) corpus_acl_mixin = CorpusACLMixin(user=self.user3) - with self.assertNumQueries(2): + with self.assertNumQueries(1): corpora = list(corpus_acl_mixin.readable_corpora) self.assertCountEqual( list(corpora), [self.corpus1, self.corpus2, self.corpus] ) + @expectedFailure def test_max_level_does_not_exists(self): with self.assertNumQueries(3): self.assertEqual(get_max_level(self.user1, self.corpus2), None) + @expectedFailure def test_max_level_user1(self): with self.assertNumQueries(3): self.assertEqual(get_max_level(self.user1, self.repo1), 80) + @expectedFailure def test_max_level_user2(self): with self.assertNumQueries(3): self.assertEqual(get_max_level(self.user2, self.repo1), 10) + @expectedFailure def test_max_level_user3(self): with self.assertNumQueries(3): self.assertEqual(get_max_level(self.user3, self.corpus2), 75) + @expectedFailure def test_max_level_public(self): with self.assertNumQueries(3): self.assertEqual(get_max_level(self.user1, self.corpus), Role.Guest.value) + @expectedFailure def test_process_access_project(self): """ A project attached to a process defines its access rights @@ -310,18 +252,7 @@ class TestACLMixin(FixtureTestCase): with self.assertNumQueries(3): self.assertEqual(ProcessACLMixin(user=self.user1).process_access_level(process), 75) - def test_process_access_docker_build(self): - """ - Access to a docker build process is possible for someone with an access to the origin repository - """ - process = Process.objects.create( - mode=ProcessMode.Repository, - creator=self.user2, - revision=self.repo1.revisions.create(hash="42", message="Message", author="User 1") - ) - with self.assertNumQueries(3): - self.assertEqual(ProcessACLMixin(user=self.user1).process_access_level(process), 80) - + @expectedFailure def test_process_access_list(self): """ A user can list process if they have rights on its corresponding project or repository @@ -345,7 +276,7 @@ class TestACLMixin(FixtureTestCase): revision=Revision.objects.get(message="My w0rk3r") ) - with self.assertNumQueries(4): + with self.assertNumQueries(1): readable_process_ids = list( ProcessACLMixin(user=self.user1).readable_processes.values_list("id", flat=True) ) @@ -358,15 +289,16 @@ class TestACLMixin(FixtureTestCase): """ User5 has guest access to Model1 via Group3 """ - with self.assertNumQueries(3): + with self.assertNumQueries(0): read_access = TrainingModelMixin(user=self.user5).has_read_access(self.model1) self.assertTrue(read_access) + @expectedFailure def test_models_no_access_user(self): """ User3 has no guest access to Model1 """ - with self.assertNumQueries(3): + with self.assertNumQueries(0): read_access = TrainingModelMixin(user=self.user3).has_read_access(self.model1) self.assertFalse(read_access) @@ -374,7 +306,7 @@ class TestACLMixin(FixtureTestCase): """ User4 has contributor access to Model1 """ - with self.assertNumQueries(3): + with self.assertNumQueries(0): write_access = TrainingModelMixin(user=self.user4).has_write_access(self.model1) self.assertTrue(write_access) @@ -382,23 +314,25 @@ class TestACLMixin(FixtureTestCase): """ User5 has admin access to Model2 via Group3's access rights """ - with self.assertNumQueries(3): + with self.assertNumQueries(0): admin_access = TrainingModelMixin(user=self.user5).has_admin_access(self.model2) self.assertTrue(admin_access) + @expectedFailure def test_models_readable(self): """ To view a model, a user needs guest access. """ - with self.assertNumQueries(3): + with self.assertNumQueries(1): readable_models = list(TrainingModelMixin(user=self.user4).readable_models) self.assertListEqual(readable_models, [self.model1]) + @expectedFailure def test_models_editable(self): """ To edit a model, a user needs contributor access. User5 only has that access on model2. """ - with self.assertNumQueries(3): + with self.assertNumQueries(1): editable_models = list(TrainingModelMixin(user=self.user5).editable_models) self.assertListEqual(editable_models, [self.model2]) diff --git a/arkindex/project/tools.py b/arkindex/project/tools.py index 6368ef0f6f..72fde5b89d 100644 --- a/arkindex/project/tools.py +++ b/arkindex/project/tools.py @@ -1,9 +1,6 @@ from collections.abc import Iterable, Iterator, Sized from datetime import datetime, timezone -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from django.db.models import Aggregate, CharField, Func from django.db.models.expressions import BaseExpression, OrderByList from django.urls import reverse @@ -186,24 +183,6 @@ class PercentileCont(OrderedSetAggregate): function = "percentile_cont" -def build_public_key() -> str: - """ - Quickly build a Ponos public key as a PEM-encoded string - """ - return ( - ec.generate_private_key( - ec.SECP384R1(), - default_backend(), - ) - .public_key() - .public_bytes( - Encoding.PEM, - PublicFormat.SubjectPublicKeyInfo, - ) - .decode("utf-8") - ) - - def fake_now(): """ Fake creation date for fixtures and test objects diff --git a/arkindex/project/triggers.py b/arkindex/project/triggers.py index 46eee4a05a..6512fc7b55 100644 --- a/arkindex/project/triggers.py +++ b/arkindex/project/triggers.py @@ -5,6 +5,7 @@ from typing import Literal, Optional, Union from uuid import UUID from django.db.models import Prefetch, prefetch_related_objects +from rq.job import Dependency from arkindex.documents import export from arkindex.documents import tasks as documents_tasks @@ -230,3 +231,17 @@ def notify_process_completion(process: Process): process=process, subject=f"Your process {process_name} finished {state_msg[state]}", ) + + +def schedule_tasks(process: Process, run: int): + """Run tasks of a process in RQ, one by one""" + tasks = process.tasks.using("default").filter(run=run).order_by("depth", "id") + # Initially mark all tasks as pending + tasks.update(state=State.Pending) + # Build a simple dependency scheme between tasks, based on depth + parent_job = None + for task in tasks: + kwargs = {} + if parent_job: + kwargs["depends_on"] = Dependency(jobs=[parent_job], allow_failure=True) + parent_job = ponos_tasks.run_task_rq.delay(task, **kwargs) diff --git a/arkindex/sql_validation/corpus_delete.sql b/arkindex/sql_validation/corpus_delete.sql index f15fa91791..a454d05cdb 100644 --- a/arkindex/sql_validation/corpus_delete.sql +++ b/arkindex/sql_validation/corpus_delete.sql @@ -158,7 +158,7 @@ WHERE "documents_selection"."id" IN DELETE FROM "users_right" WHERE ("users_right"."content_id" = '{corpus_id}'::uuid - AND "users_right"."content_type_id" = 20); + AND "users_right"."content_type_id" = 19); DELETE FROM "documents_corpusexport" diff --git a/arkindex/sql_validation/corpus_delete_top_level_type.sql b/arkindex/sql_validation/corpus_delete_top_level_type.sql index 7acf28db77..9c7ae8cd60 100644 --- a/arkindex/sql_validation/corpus_delete_top_level_type.sql +++ b/arkindex/sql_validation/corpus_delete_top_level_type.sql @@ -162,7 +162,7 @@ WHERE "documents_selection"."id" IN DELETE FROM "users_right" WHERE ("users_right"."content_id" = '{corpus_id}'::uuid - AND "users_right"."content_type_id" = 20); + AND "users_right"."content_type_id" = 19); DELETE FROM "documents_corpusexport" diff --git a/arkindex/training/api.py b/arkindex/training/api.py index c8d6ce5830..55acaf6f0d 100644 --- a/arkindex/training/api.py +++ b/arkindex/training/api.py @@ -392,7 +392,7 @@ class ModelsList(TrainingModelMixin, ListCreateAPIView): filters &= Q(archived__isnull=self.request.query_params["archived"].lower().strip() in ("false", "0")) # Use the default database to prevent a stale read when a model has just been created - return self.readable_models.using("default").filter(filters).order_by("name") + return Model.objects.readable(self.request.user).using("default").filter(filters).order_by("name") def perform_create(self, serializer): model_name = serializer.validated_data.get("name") @@ -443,7 +443,7 @@ class ModelRetrieve(TrainingModelMixin, RetrieveUpdateAPIView): serializer_class = ModelSerializer def get_queryset(self): - return self.readable_models + return Model.objects.readable(self.request.user) def check_object_permissions(self, request, obj): super().check_object_permissions(request, obj) diff --git a/arkindex/training/tests/test_datasets_api.py b/arkindex/training/tests/test_datasets_api.py index f6a935b162..a97ad190a6 100644 --- a/arkindex/training/tests/test_datasets_api.py +++ b/arkindex/training/tests/test_datasets_api.py @@ -1,5 +1,5 @@ import uuid -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from django.urls import reverse from django.utils import timezone as DjangoTimeZone @@ -61,13 +61,18 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Not found."}) - def test_list_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_list_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.get(reverse("api:corpus-datasets", kwargs={"pk": self.private_corpus.pk})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have guest access to this corpus."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Guest.value, skip_public=False)) + def test_list(self): self.client.force_login(self.user) with self.assertNumQueries(5): @@ -137,20 +142,25 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Not found."}) - def test_create_private_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_private_corpus(self, has_access_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.private_corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments."}, format="json" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have contributor access to this corpus."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.private_corpus, Role.Contributor.value, skip_public=False)) + def test_create_name_required(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"description": "My dataset for my experiments."}, @@ -161,7 +171,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_name_empty(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "", "description": "My dataset for my experiments."}, @@ -172,7 +182,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_name_blank(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": " ", "description": "My dataset for my experiments."}, @@ -183,7 +193,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={ @@ -197,7 +207,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_name_already_exists_in_corpus(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={ @@ -211,7 +221,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_description_required(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset"}, @@ -222,7 +232,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_description_empty(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": ""}, @@ -233,7 +243,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_description_blank(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": " "}, @@ -244,7 +254,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments."}, @@ -272,7 +282,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_state_ignored(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments.", "state": "complete"}, @@ -297,7 +307,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_sets(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments.", "sets": ["a", "b", "c", "d"]}, @@ -321,7 +331,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_sets_length(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments.", "sets": []}, @@ -332,7 +342,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_sets_unique_names(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments.", "sets": ["a", "a", "b"]}, @@ -343,7 +353,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_sets_blank_names(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={"name": "My dataset", "description": "My dataset for my experiments.", "sets": [" ", " ", "b"]}, @@ -360,7 +370,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_create_sets_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:corpus-datasets", kwargs={"pk": self.corpus.pk}), data={ @@ -409,9 +419,10 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_update_requires_write_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_requires_write_corpus(self, has_access_mock): self.client.force_login(self.read_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -424,9 +435,12 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have contributor access to corpus Unit Tests."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.read_user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_update_doesnt_exist(self): self.client.force_login(self.read_user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}), data={ @@ -441,7 +455,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -456,7 +470,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_name_already_exists_in_corpus(self): Dataset.objects.create(name="Another Dataset", description="A set of data", corpus=self.corpus, creator=self.dataset_creator) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -471,7 +485,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_requires_all_fields(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={"name": "Shin Seiki Evangelion"}, @@ -483,7 +497,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_add_sets(self): self.client.force_login(self.user) self.assertIsNone(self.dataset.task_id) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -509,7 +523,7 @@ class TestDatasetsAPI(FixtureAPITestCase): ProcessDataset.objects.get(process=self.process, dataset=self.dataset).delete() self.client.force_login(self.user) dataset_elt = self.dataset.dataset_elements.create(element=self.page1, set="training") - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -542,7 +556,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element_id=self.page1.id, set="training") self.dataset.dataset_elements.create(element_id=self.page2.id, set="validation") self.dataset.dataset_elements.create(element_id=self.page3.id, set="validation") - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -577,7 +591,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element_id=self.page1.id, set="training") self.dataset.dataset_elements.create(element_id=self.page2.id, set="validation") self.dataset.dataset_elements.create(element_id=self.page3.id, set="validation") - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -612,7 +626,7 @@ class TestDatasetsAPI(FixtureAPITestCase): # Remove ProcessDataset relation ProcessDataset.objects.get(process=self.process, dataset=self.dataset).delete() self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -627,7 +641,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_sets_length(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -642,7 +656,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_sets_unique_names(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -657,7 +671,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_sets_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -676,7 +690,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_empty_or_blank_description_or_name(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -695,7 +709,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_all_errors(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -716,7 +730,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) self.dataset.state = DatasetState.Building self.dataset.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -734,7 +748,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_ponos_task_state_update(self): self.dataset.state = DatasetState.Building self.dataset.save() - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -799,7 +813,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) for new_state in DatasetState: with self.subTest(new_state=new_state): - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -818,7 +832,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_update_ponos_task_state_requires_dataset_in_process(self): self.process.process_datasets.all().delete() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -835,7 +849,7 @@ class TestDatasetsAPI(FixtureAPITestCase): }) def test_update_ponos_task_bad_state(self): - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.put( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -875,9 +889,10 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_partial_update_requires_write_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_requires_write_corpus(self, has_access_mock): self.client.force_login(self.read_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={"name": "Shin Seiki Evangelion"}, @@ -886,9 +901,12 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have contributor access to corpus Unit Tests."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.read_user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_partial_update_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -902,7 +920,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update_name_already_exists_in_corpus(self): Dataset.objects.create(name="Another Dataset", description="A set of data", corpus=self.corpus, creator=self.dataset_creator) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -915,7 +933,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update(self): self.client.force_login(self.user) - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -932,7 +950,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update_empty_or_blank_description_or_name(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -946,7 +964,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update_requires_ponos_auth(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -960,7 +978,7 @@ class TestDatasetsAPI(FixtureAPITestCase): }) def test_partial_update_ponos_task_state_update(self): - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -975,7 +993,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update_ponos_task_state_requires_dataset_in_process(self): self.process.process_datasets.all().delete() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -990,7 +1008,7 @@ class TestDatasetsAPI(FixtureAPITestCase): }) def test_partial_update_ponos_task_bad_state(self): - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), HTTP_AUTHORIZATION=f"Ponos {self.task.token}", @@ -1006,7 +1024,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_partial_update_sets_name_too_long(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -1069,7 +1087,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) for new_state in DatasetState: with self.subTest(new_state=new_state): - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), data={ @@ -1106,18 +1124,24 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_retrieve_requires_read_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_retrieve_requires_read_corpus(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + + with self.assertNumQueries(2): response = self.client.get( reverse("api:dataset-update", kwargs={"pk": self.private_dataset.pk}) ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertDictEqual(response.json(), {"detail": "Not found."}) - def test_retrieve_doesnt_exists(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + + def test_retrieve_doesnt_exist(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get( reverse("api:dataset-update", kwargs={"pk": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}) ) @@ -1126,7 +1150,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_retrieve(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}) ) @@ -1149,7 +1173,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) self.dataset.task = self.task self.dataset.save() - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}) ) @@ -1176,18 +1200,22 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_delete_requires_write_corpus(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_delete_requires_corpus_admin(self, has_access_mock): self.client.force_login(self.write_user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have admin access to corpus Unit Tests."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.write_user, self.corpus, Role.Admin.value, skip_public=False)) + def test_delete_doesnt_exist(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:dataset-update", kwargs={"pk": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}), ) @@ -1196,7 +1224,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_delete(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), ) @@ -1211,7 +1239,7 @@ class TestDatasetsAPI(FixtureAPITestCase): with self.subTest(state=state): self.dataset.state = state self.dataset.save() - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.delete( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), ) @@ -1229,7 +1257,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element_id=self.page3.id, set="validation") self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:dataset-update", kwargs={"pk": self.dataset.pk}), ) @@ -1253,20 +1281,24 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_list_elements_invalid_dataset_id(self): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:dataset-elements", kwargs={"pk": str(uuid.uuid4())})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_list_elements_readable_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_list_elements_readable_corpus(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.get(reverse("api:dataset-elements", kwargs={"pk": str(self.private_dataset.id)})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_list_elements_set_filter_wrong_set(self): self.dataset.dataset_elements.create(element_id=self.page1.id, set="test") self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:dataset-elements", kwargs={"pk": str(self.dataset.id)}), data={"set": "aaaaa"} @@ -1283,7 +1315,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element_id=self.page1.id, set="test") self.dataset.dataset_elements.create(element_id=self.page2.id, set="training") self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get( reverse("api:dataset-elements", kwargs={"pk": self.dataset.pk}), data={"set": "training", "with_count": "true"}, @@ -1491,7 +1523,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.state = state self.dataset.save() with self.subTest(state=state): - with self.assertNumQueries(6): + with self.assertNumQueries(4): response = self.client.get( reverse("api:dataset-elements", kwargs={"pk": self.dataset.pk}), {"page_size": 3}, @@ -1516,18 +1548,23 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_add_element_private_dataset(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_add_element_private_dataset(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.private_dataset.id}) ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_add_element_requires_writable_corpus(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_add_element_requires_writable_corpus(self, has_access_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}) ) @@ -1536,9 +1573,12 @@ class TestDatasetsAPI(FixtureAPITestCase): "detail": "You do not have contributor access to the corpus of this dataset." }) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_add_element_required_fields(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:dataset-elements", kwargs={"pk": self.dataset.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -1549,7 +1589,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_element_wrong_element(self): element = self.private_corpus.elements.create(type=self.private_corpus.types.create(slug="folder")) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}), data={"set": "test", "element_id": str(element.id)}, @@ -1562,7 +1602,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_element_wrong_set(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}), data={"set": "aaaaaaaaaaa", "element_id": str(self.vol.id)}, @@ -1577,7 +1617,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) self.dataset.state = DatasetState.Complete self.dataset.save() - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}), data={"set": "test", "element_id": str(self.vol.id)}, @@ -1589,7 +1629,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_element_already_exists(self): self.dataset.dataset_elements.create(element=self.page1, set="test") self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}), data={"set": "test", "element_id": str(self.page1.id)}, @@ -1600,7 +1640,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_element(self): self.client.force_login(self.user) - with self.assertNumQueries(13): + with self.assertNumQueries(10): response = self.client.post( reverse("api:dataset-elements", kwargs={"pk": self.dataset.id}), data={"set": "training", "element_id": str(self.page1.id)}, @@ -1636,18 +1676,23 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_add_from_selection_private_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_add_from_selection_private_corpus(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.private_corpus.id}) ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_add_from_selection_requires_writable_corpus(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_add_from_selection_requires_writable_corpus(self, has_access_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}) ) @@ -1656,9 +1701,12 @@ class TestDatasetsAPI(FixtureAPITestCase): "detail": "You need a Contributor access to the corpus to perform this action." }) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_add_from_selection_required_fields(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), { @@ -1668,7 +1716,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_from_selection_wrong_values(self): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}), data={"set": {}, "dataset_id": "AAA"}, @@ -1682,7 +1730,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_from_selection_wrong_dataset(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}), data={"set": "aaa", "dataset_id": self.private_dataset.id}, @@ -1698,7 +1746,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.client.force_login(self.user) self.dataset.state = DatasetState.Complete self.dataset.save() - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}), data={"set": "aaa", "dataset_id": self.dataset.id}, @@ -1711,7 +1759,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_add_from_selection_wrong_set(self): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}), data={"set": "aaa", "dataset_id": self.dataset.id}, @@ -1731,7 +1779,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.user.selected_elements.set([self.vol, self.page1, self.page2]) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:dataset-elements-selection", kwargs={"pk": self.corpus.id}), data={"set": "training", "dataset_id": self.dataset.id}, @@ -1747,15 +1795,17 @@ class TestDatasetsAPI(FixtureAPITestCase): ] ) - # ListElementDatasets - - def test_element_datasets_requires_read_access(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_element_datasets_requires_read_access(self, filter_rights_mock): self.client.force_login(self.user) private_elt = self.private_corpus.elements.create(type=self.private_corpus.types.create(slug="t"), name="elt") - with self.assertNumQueries(5): + with self.assertNumQueries(2): response = self.client.get(reverse("api:element-datasets", kwargs={"pk": private_elt.id})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + def test_element_datasets_methods(self): self.client.force_login(self.user) forbidden_methods = ("post", "patch", "put", "delete") @@ -1803,7 +1853,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element=self.page1, set="train") self.dataset.dataset_elements.create(element=self.page1, set="validation") self.dataset2.dataset_elements.create(element=self.page1, set="train") - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:element-datasets", kwargs={"pk": str(self.page1.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(response.json(), { @@ -1870,7 +1920,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element=self.page1, set="train") self.dataset.dataset_elements.create(element=self.page1, set="validation") self.dataset2.dataset_elements.create(element=self.page1, set="train") - with self.assertNumQueries(7): + with self.assertNumQueries(5): response = self.client.get(reverse("api:element-datasets", kwargs={"pk": str(self.page1.id)}), {"with_neighbors": False}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(response.json(), { @@ -1947,7 +1997,7 @@ class TestDatasetsAPI(FixtureAPITestCase): sorted_dataset2_elements = sorted([str(self.page1.id), str(self.page3.id)]) page1_index_2 = sorted_dataset2_elements.index(str(self.page1.id)) - with self.assertNumQueries(9): + with self.assertNumQueries(7): response = self.client.get(reverse("api:element-datasets", kwargs={"pk": str(self.page1.id)}), {"with_neighbors": True}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(response.json(), { @@ -2014,12 +2064,12 @@ class TestDatasetsAPI(FixtureAPITestCase): "set": "train", "previous": ( sorted_dataset2_elements[page1_index_2 - 1] - if page1_index_1 - 1 >= 0 + if page1_index_2 - 1 >= 0 else None ), "next": ( sorted_dataset2_elements[page1_index_2 + 1] - if page1_index_1 + 1 <= 1 + if page1_index_2 + 1 <= 2 else None ) }] @@ -2051,26 +2101,36 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_clone_private_corpus(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_clone_private_corpus(self, filter_rights_mock): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:dataset-clone", kwargs={"pk": self.private_dataset.id}) ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_clone_requires_writable_corpus(self): + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user, Corpus, Role.Guest.value)) + + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_clone_requires_writable_corpus(self, has_access_mock): self.corpus.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(9): + + with self.assertNumQueries(7): response = self.client.post( reverse("api:dataset-clone", kwargs={"pk": self.dataset.id}) ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), { "detail": "You need a Contributor access to the dataset's corpus." }) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_clone(self): self.dataset.creator = self.superuser self.dataset.state = DatasetState.Error @@ -2082,7 +2142,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertCountEqual(self.corpus.datasets.values_list("name", flat=True), ["First Dataset", "Second Dataset"]) self.client.force_login(self.user) - with self.assertNumQueries(15): + with self.assertNumQueries(12): response = self.client.post( reverse("api:dataset-clone", kwargs={"pk": self.dataset.id}), format="json", @@ -2124,7 +2184,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_clone_existing_name(self): self.corpus.datasets.create(name="Clone of First Dataset", creator=self.user) self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(11): response = self.client.post( reverse("api:dataset-clone", kwargs={"pk": self.dataset.id}), format="json", @@ -2158,7 +2218,7 @@ class TestDatasetsAPI(FixtureAPITestCase): def test_clone_name_too_long(self): dataset = self.corpus.datasets.create(name="A" * 99, creator=self.user) self.client.force_login(self.user) - with self.assertNumQueries(14): + with self.assertNumQueries(11): response = self.client.post( reverse("api:dataset-clone", kwargs={"pk": dataset.id}), format="json", @@ -2191,13 +2251,14 @@ class TestDatasetsAPI(FixtureAPITestCase): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_destroy_dataset_element_requires_contributor(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_destroy_dataset_element_requires_contributor(self, has_access_mock): self.client.force_login(self.read_user) self.dataset.dataset_elements.create(element=self.page1, set="train") self.dataset.dataset_elements.create(element=self.page1, set="validation") self.assertEqual(self.dataset.dataset_elements.filter(set="train").count(), 1) self.assertEqual(self.dataset.dataset_elements.filter(set="validation").count(), 1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse( "api:dataset-element", kwargs={"dataset": str(self.dataset.id), "element": str(self.page1.id)}) @@ -2209,6 +2270,9 @@ class TestDatasetsAPI(FixtureAPITestCase): self.assertEqual(self.dataset.dataset_elements.filter(set="train").count(), 1) self.assertEqual(self.dataset.dataset_elements.filter(set="validation").count(), 1) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.read_user, self.corpus, Role.Contributor.value, skip_public=False)) + def test_destroy_dataset_element_set_required(self): self.client.force_login(self.user) with self.assertNumQueries(2): @@ -2303,7 +2367,7 @@ class TestDatasetsAPI(FixtureAPITestCase): self.dataset.dataset_elements.create(element=self.page1, set="validation") self.assertEqual(self.dataset.dataset_elements.filter(set="train").count(), 1) self.assertEqual(self.dataset.dataset_elements.filter(set="validation").count(), 1) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.delete(reverse( "api:dataset-element", kwargs={"dataset": str(self.dataset.id), "element": str(self.page1.id)}) diff --git a/arkindex/training/tests/test_metrics_api.py b/arkindex/training/tests/test_metrics_api.py index 4876b9904b..a1efc6de3c 100644 --- a/arkindex/training/tests/test_metrics_api.py +++ b/arkindex/training/tests/test_metrics_api.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import call, patch from django.db import IntegrityError from django.urls import reverse @@ -73,10 +73,10 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"model_version_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.']}) - def test_create_metric_value_requires_contributor(self): - self.user.rights.all().delete() + @patch("arkindex.training.serializers.get_max_level", return_value=None) + def test_create_metric_value_requires_contributor(self, get_max_level_mock): self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:metric-create"), data={ @@ -89,12 +89,15 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have contributor access to this model."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.model)) + def test_create_metric_value_archived(self): self.model.archived = datetime.now(timezone.utc) self.model.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:metric-create"), data={ @@ -118,7 +121,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.client.force_login(self.user) self.assertEqual(len(MetricKey.objects.filter(model_version=self.model_version)), 1) self.assertEqual(len(MetricValue.objects.filter(metric=self.metric_key)), 0) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:metric-create"), data={ @@ -147,7 +150,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.client.force_login(self.user) with self.assertRaises(MetricKey.DoesNotExist): MetricKey.objects.get(name="my_metric") - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.post( reverse("api:metric-create"), data={ @@ -176,7 +179,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.client.force_login(self.user) with self.assertRaises(MetricKey.DoesNotExist): MetricKey.objects.get(name="my_metric") - with self.assertNumQueries(11): + with self.assertNumQueries(8): response = self.client.post( reverse("api:metric-create"), data={ @@ -206,7 +209,7 @@ class TestMetricsAPI(FixtureAPITestCase): Cannot create a metric value of type point using the step property """ self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(7): response = self.client.post( reverse("api:metric-create"), data={ @@ -226,7 +229,7 @@ class TestMetricsAPI(FixtureAPITestCase): Cannot create a metric value using the step property for a metric key of type point """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -245,7 +248,7 @@ class TestMetricsAPI(FixtureAPITestCase): Cannot create a metric value for an existing metric key with a different mode """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -266,7 +269,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricValue.objects.create(metric=self.metric_key, value=2.3) self.assertEqual(MetricKey.objects.get(name="a test metric").values.count(), 1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -296,7 +299,7 @@ class TestMetricsAPI(FixtureAPITestCase): metric_key = MetricKey.objects.create(name="a series metric", mode=MetricMode.Series, model_version=self.model_version) MetricValue.objects.create(metric=metric_key, value=3, step=1) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:metric-create"), data={ @@ -324,7 +327,7 @@ class TestMetricsAPI(FixtureAPITestCase): metric_key = MetricKey.objects.create(name="a series metric", mode=MetricMode.Series, model_version=self.model_version) MetricValue.objects.create(metric=metric_key, value=3, step=1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -348,7 +351,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -371,7 +374,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(len(MetricValue.objects.filter(metric=series_metric)), 1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metric-create"), data={ @@ -435,10 +438,11 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"model_version_id": ['Invalid pk "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - object does not exist.']}) - def test_bulk_create_metric_value_requires_contributor(self): + @patch("arkindex.training.serializers.get_max_level", return_value=None) + def test_bulk_create_metric_value_requires_contributor(self, get_max_level_mock): self.user.rights.all().delete() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:metrics-create"), data={ @@ -460,12 +464,15 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have contributor access to this model."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user, self.model)) + def test_bulk_create_metric_value_archived(self): self.model.archived = datetime.now(timezone.utc) self.model.save() self.client.force_login(self.user) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:metrics-create"), data={ @@ -493,7 +500,7 @@ class TestMetricsAPI(FixtureAPITestCase): Cannot send two metrics values for the same metric key / name """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -525,7 +532,7 @@ class TestMetricsAPI(FixtureAPITestCase): test_metric = MetricKey.objects.create(name="metric numero duo", model_version=self.model_version, mode=MetricMode.Series) self.client.force_login(self.user) self.assertEqual(len(MetricKey.objects.filter(model_version=self.model_version)), 2) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:metrics-create"), data={ @@ -569,7 +576,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricKey.objects.get(name="some metric") with self.assertRaises(MetricKey.DoesNotExist): MetricKey.objects.get(name="another metric") - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.post( reverse("api:metrics-create"), data={ @@ -621,7 +628,7 @@ class TestMetricsAPI(FixtureAPITestCase): test_metric_2 = MetricKey.objects.create(name="metric number three", model_version=self.model_version, mode=MetricMode.Series) datetime_mock.return_value = datetime(2046, 1, 1, 12, 34, 56, tzinfo=timezone.utc) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:metrics-create"), data={ @@ -661,7 +668,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricKey.objects.create(name="metric numero duo", model_version=self.model_version, mode=MetricMode.Point) MetricKey.objects.create(name="another metric", model_version=self.model_version, mode=MetricMode.Series) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -703,7 +710,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricValue.objects.create(metric=self.metric_key, value=2.3) self.assertEqual(MetricKey.objects.get(name="a test metric").values.count(), 1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -730,7 +737,7 @@ class TestMetricsAPI(FixtureAPITestCase): Cannot create a metric value for an existing metric key with a different mode (bulk edition) """ self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -756,7 +763,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricValue.objects.create(metric=series_metric, step=1, value=0.2) self.assertEqual(len(MetricValue.objects.filter(metric=series_metric)), 1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -783,7 +790,7 @@ class TestMetricsAPI(FixtureAPITestCase): MetricKey.objects.create(name="another metric", model_version=self.model_version, mode=MetricMode.Series) MetricValue.objects.create(metric=metric_duo, value=56) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -844,7 +851,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ @@ -871,7 +878,7 @@ class TestMetricsAPI(FixtureAPITestCase): self.assertEqual(len(MetricValue.objects.filter(metric=series_metric)), 1) self.client.force_login(self.user) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:metrics-create"), data={ diff --git a/arkindex/training/tests/test_model_api.py b/arkindex/training/tests/test_model_api.py index b69071b158..7a264b2f3f 100644 --- a/arkindex/training/tests/test_model_api.py +++ b/arkindex/training/tests/test_model_api.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import call, patch from uuid import uuid4 from django.urls import reverse @@ -94,6 +94,7 @@ class TestModelAPI(FixtureAPITestCase): def test_create_model_version_requires_verified(self): user = User.objects.create(email="not_verified@mail.com", display_name="Not Verified", verified_email=False) self.client.force_login(user) + with self.assertNumQueries(2): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model2.id)}), @@ -104,25 +105,31 @@ class TestModelAPI(FixtureAPITestCase): }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_create_model_version_requires_contributor(self): + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_create_model_version_requires_contributor(self, get_max_level_mock): """ Can't create model version as guest """ self.client.force_login(self.user1) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.post(reverse("api:model-versions", kwargs={"pk": str(self.model2.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need a Contributor access to the model to create a new version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user1, self.model2)) def test_create_model_version_archived(self): self.model1.archived = timezone.now() self.model1.save() self.client.force_login(self.user1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:model-versions", kwargs={"pk": str(self.model1.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -140,7 +147,7 @@ class TestModelAPI(FixtureAPITestCase): # To mock the creation date with patch("django.utils.timezone.now") as mock_now: mock_now.return_value = fake_now - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model1.id)}), {}, @@ -175,7 +182,7 @@ class TestModelAPI(FixtureAPITestCase): Raise 400 when creating a model version with a blank tag """ self.client.force_login(self.user1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model1.id)}), {"tag": ""}, @@ -190,7 +197,7 @@ class TestModelAPI(FixtureAPITestCase): Raise 400 when creating a model version with an existing tag for the same model """ self.client.force_login(self.user1) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model1.id)}), {"tag": "tagged"}, @@ -211,7 +218,7 @@ class TestModelAPI(FixtureAPITestCase): # To mock the creation date with patch("django.utils.timezone.now") as mock_now: mock_now.return_value = fake_now - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model1.id)}), { @@ -249,7 +256,7 @@ class TestModelAPI(FixtureAPITestCase): Raises 400 when creating a model version that already exists, same model_id and tag """ self.client.force_login(self.user1) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-versions", kwargs={"pk": str(self.model1.id)}), {"tag": self.model_version2.tag}, @@ -273,41 +280,21 @@ class TestModelAPI(FixtureAPITestCase): response = self.client.get(reverse("api:model-retrieve", kwargs={"pk": str(self.model2.id)})) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_retrieve_model_requires_guest(self): - self.assertFalse(self.model1.public) - self.assertFalse(self.model1.memberships.filter(user=self.user3).exists()) + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Model.objects.none()) + def test_retrieve_model_requires_guest(self, filter_rights_mock): self.client.force_login(self.user3) - with self.assertNumQueries(4): + with self.assertNumQueries(2): response = self.client.get(reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_retrieve_model_public(self): - self.assertFalse(self.model1.memberships.filter(user=self.user3).exists()) - self.model1.public = True - self.model1.save() - self.client.force_login(self.user3) - - with self.assertNumQueries(5): - response = self.client.get(reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual(response.json(), { - "id": str(self.model1.id), - "created": self.model1.created.isoformat().replace("+00:00", "Z"), - "updated": self.model1.updated.isoformat().replace("+00:00", "Z"), - "name": "First Model", - "description": "first", - "rights": ["read"], - "archived": False, - }) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(self.user3, Model, Role.Guest.value)) def test_retrieve_model(self): - self.assertFalse(self.model2.public) - self.assertTrue(self.model2.memberships.filter(user=self.user3).exists()) self.client.force_login(self.user3) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-retrieve", kwargs={"pk": str(self.model2.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -317,7 +304,7 @@ class TestModelAPI(FixtureAPITestCase): "updated": self.model2.updated.isoformat().replace("+00:00", "Z"), "name": "Second Model", "description": "", - "rights": ["read"], + "rights": ["read", "write", "admin"], "archived": False, }) @@ -336,24 +323,29 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_update_model_requires_contrib(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_update_model_requires_contributor(self, has_access_mock): self.assertFalse(self.model1.public) self.model1.memberships.create(user=self.user3, level=Role.Guest.value) self.client.force_login(self.user3) - with self.assertNumQueries(5): + + with self.assertNumQueries(3): response = self.client.put( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"name": "new name", "description": "test"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.model1.refresh_from_db() self.assertEqual((self.model1.name, self.model1.description), ("First Model", "first")) self.assertDictEqual(response.json(), {"detail": "You do not have a contributor access to this model."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user3, self.model1, Role.Contributor.value, skip_public=False)) def test_update_model_unique_name(self): self.client.force_login(self.user1) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.put( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"name": self.model2.name, "description": "", "public": True}, @@ -362,21 +354,22 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"name": ["A model with this name already exists"]}) - def test_update_model_archived_requires_archivable(self): - self.assertFalse(self.model1.is_archivable(self.user2)) + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_update_model_archived_requires_archivable(self, get_max_level_mock): self.client.force_login(self.user2) + self.assertFalse(self.model1.is_archivable(self.user2)) cases = [ (timezone.now(), False), (None, True), ] - for current_value, new_value in cases: with self.subTest(current_value=current_value, new_value=new_value): + get_max_level_mock.reset_mock() self.model1.archived = current_value self.model1.save() - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.put( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"name": "new name", "description": "test", "archived": new_value}, @@ -385,6 +378,8 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"archived": ["You are not allowed to archive or unarchive this model."]}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user2, self.model1)) def test_update_model_archived(self): self.assertTrue(self.model1.is_archivable(self.user1)) @@ -400,7 +395,7 @@ class TestModelAPI(FixtureAPITestCase): self.model1.archived = current_value self.model1.save() - with self.assertNumQueries(10): + with self.assertNumQueries(5): response = self.client.put( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"name": "new name", "description": "test", "archived": new_value}, @@ -425,7 +420,7 @@ class TestModelAPI(FixtureAPITestCase): def test_update_model(self): self.assertFalse(self.model1.public) self.client.force_login(self.user1) - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.put( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"name": "new name", "description": "", "public": True}, @@ -465,24 +460,29 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - def test_partial_update_model_requires_contrib(self): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_partial_update_model_requires_contributor(self, has_access_mock): self.assertFalse(self.model1.public) - self.model1.memberships.create(user=self.user3, level=Role.Guest.value) self.client.force_login(self.user3) - with self.assertNumQueries(5): + + with self.assertNumQueries(3): response = self.client.patch( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"description": "test"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.model1.refresh_from_db() self.assertEqual((self.model1.name, self.model1.description), ("First Model", "first")) self.assertDictEqual(response.json(), {"detail": "You do not have a contributor access to this model."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user3, self.model1, Role.Contributor.value, skip_public=False)) + def test_partial_update_model(self): self.client.force_login(self.user1) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"description": "new desc", "public": True}, @@ -506,7 +506,8 @@ class TestModelAPI(FixtureAPITestCase): } ) - def test_partial_update_model_archived_requires_archivable(self): + @patch("arkindex.users.utils.get_max_level", return_value=Role.Contributor.value) + def test_partial_update_model_archived_requires_archivable(self, get_max_level_mock): self.assertFalse(self.model1.is_archivable(self.user2)) self.client.force_login(self.user2) @@ -514,13 +515,13 @@ class TestModelAPI(FixtureAPITestCase): (timezone.now(), False), (None, True), ] - for current_value, new_value in cases: with self.subTest(current_value=current_value, new_value=new_value): + get_max_level_mock.reset_mock() self.model1.archived = current_value self.model1.save() - with self.assertNumQueries(7): + with self.assertNumQueries(3): response = self.client.patch( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"archived": new_value}, @@ -529,6 +530,8 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"archived": ["You are not allowed to archive or unarchive this model."]}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user2, self.model1)) def test_partial_update_model_archived(self): self.assertTrue(self.model1.is_archivable(self.user1)) @@ -544,7 +547,7 @@ class TestModelAPI(FixtureAPITestCase): self.model1.archived = current_value self.model1.save() - with self.assertNumQueries(9): + with self.assertNumQueries(4): response = self.client.patch( reverse("api:model-retrieve", kwargs={"pk": str(self.model1.id)}), {"archived": new_value}, @@ -569,59 +572,77 @@ class TestModelAPI(FixtureAPITestCase): def test_list_model_versions_requires_logged_in(self): """To list a model's versions, you need to be logged in. """ - response = self.client.get(reverse("api:model-versions", kwargs={"pk": str(self.model1.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + with self.assertNumQueries(0): + response = self.client.get(reverse("api:model-versions", kwargs={"pk": str(self.model1.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_list_model_versions_requires_guest_access(self): - """To view a model's version, you need guest access on the model - """ + @patch("arkindex.training.api.get_max_level", return_value=None) + def test_list_model_versions_requires_guest_access(self, get_max_level_mock): self.client.force_login(self.user1) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-versions", kwargs={"pk": str(self.model2.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "You need a Guest access to list versions of a model."}) - def test_list_model_versions_low_access(self): + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_list_model_versions_low_access(self, get_max_level_mock): """With only guest access rights on a model, you only see the available versions with a set tag """ self.client.force_login(self.user3) - with self.assertNumQueries(8): + + with self.assertNumQueries(5): response = self.client.get(reverse("api:model-versions", kwargs={"pk": str(self.model2.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( [version["id"] for version in response.json()["results"]], [str(self.model_version3.id)], ) - def test_list_model_versions_full_access(self): + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) + + @patch("arkindex.training.api.get_max_level", return_value=Role.Contributor.value) + def test_list_model_versions_full_access(self, get_max_level_mock): """With contributor access rights, you see all versions. """ self.client.force_login(self.user2) - with self.assertNumQueries(8): + + with self.assertNumQueries(5): response = self.client.get(reverse("api:model-versions", kwargs={"pk": str(self.model2.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual( [version["id"] for version in response.json()["results"]], [str(self.model_version4.id), str(self.model_version3.id)], ) - def test_destroy_model_versions_requires_admin(self): - """To destroy a model version, you need admin rights on the model. - """ + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user2, self.model2)) + + @patch("arkindex.training.api.get_max_level", return_value=Role.Contributor.value) + def test_destroy_model_versions_requires_admin(self, get_max_level_mock): self.client.force_login(self.user2) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.delete(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need an Admin access to the model to destroy this version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user2, self.model1)) + def test_destroy_model_versions_archived(self): self.model1.archived = timezone.now() self.model1.save() self.client.force_login(self.user1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.delete(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)})) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -632,20 +653,27 @@ class TestModelAPI(FixtureAPITestCase): This also deletes every worker run that used this model version """ self.client.force_login(self.user1) - with self.assertNumQueries(12): + with self.assertNumQueries(9): response = self.client.delete(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - def test_retrieve_model_versions_require_guest(self): - """To retrieve a model version with a set tag and state==Available, you need guest rights on the model. + @patch("arkindex.training.api.get_max_level", return_value=None) + def test_retrieve_model_versions_require_guest(self, get_max_level_mock): + """ + To retrieve a model version with a set tag and state==Available, you need guest rights on the model. """ self.client.force_login(self.user1) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need a Guest access to the model to retrieve this version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user1, self.model2)) - def test_retrieve_model_versions_tag_available(self): + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_retrieve_model_versions_tag_available(self, get_max_level_mock): """ Retrieve a model version with a set tag and state==Available with guest rights on the model. No s3 URL is set with a guest access. @@ -653,9 +681,11 @@ class TestModelAPI(FixtureAPITestCase): self.assertIsNotNone(self.model_version3.tag) self.assertEqual(self.model_version3.state, ModelVersionState.Available) self.client.force_login(self.user3) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "id": str(self.model_version3.id), "model_id": str(self.model2.id), @@ -670,38 +700,55 @@ class TestModelAPI(FixtureAPITestCase): "s3_url": None, "s3_put_url": None, }) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) - def test_retrieve_model_versions_no_tag_requires_contributor(self): - """To retrieve a model version with no set tag or state!=Available, you need contributor rights on the model. + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_retrieve_model_versions_no_tag_requires_contributor(self, get_max_level_mock): + """ + To retrieve a model version with no set tag or state!=Available, you need contributor rights on the model. """ self.assertEqual(self.model_version4.tag, None) self.client.force_login(self.user3) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version4.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need a Contributor access to the model to retrieve this version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) - def test_retrieve_model_versions_no_validated_requires_contributor(self): - """To retrieve a model version with no set tag or state!=Available, you need contributor rights on the model. + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_retrieve_model_versions_no_validated_requires_contributor(self, get_max_level_mock): + """ + To retrieve a model version with no set tag or state!=Available, you need contributor rights on the model. """ self.model_version4.tag = "A tag" self.model_version4.state = ModelVersionState.Error self.model_version4.save() self.client.force_login(self.user3) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version4.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need a Contributor access to the model to retrieve this version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) - @patch("arkindex.project.aws.s3.meta.client.generate_presigned_url") - def test_retrieve_model_versions(self, s3_presigned_url): - """Retrieve a model version with no set tag or state!=Available with contributor rights on the model. + @patch("arkindex.training.api.get_max_level", return_value=Role.Contributor.value) + @patch("arkindex.project.aws.s3.meta.client.generate_presigned_url", return_value="http://s3/get_url") + def test_retrieve_model_versions(self, s3_presigned_url, get_max_level_mock): + """ + Retrieve a model version with no set tag or state!=Available with contributor rights on the model. """ - s3_presigned_url.return_value = "http://s3/get_url" self.client.force_login(self.user2) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.get(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version4.id)})) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.json(), { "id": str(self.model_version4.id), "model_id": str(self.model2.id), @@ -717,24 +764,28 @@ class TestModelAPI(FixtureAPITestCase): "s3_put_url": None, }) - @patch("arkindex.project.aws.s3.Object") - @patch("arkindex.project.aws.S3FileMixin.exists") - def test_partial_update_model_version_requires_contributor(self, exists, s3_object): + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user2, self.model2)) + + @patch("arkindex.training.api.get_max_level", return_value=Role.Guest.value) + def test_partial_update_model_version_requires_contributor(self, get_max_level_mock): """ Can't partial update a model version as guest """ - s3_object().content_length = self.model_version3.size - s3_object().e_tag = self.model_version3.archive_hash - exists.return_value = True self.client.force_login(self.user3) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.patch(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)}), {"state": "available"}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual( response.json(), {"detail": "You need a Contributor access to the model to update this version."} ) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) + @patch("arkindex.project.aws.s3.Object") @patch("arkindex.project.aws.S3FileMixin.exists") def test_partial_update_model_version_archived(self, exists, s3_object): @@ -745,7 +796,7 @@ class TestModelAPI(FixtureAPITestCase): self.model1.save() self.client.force_login(self.user1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.patch(reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)}), {"state": "available"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -775,7 +826,7 @@ class TestModelAPI(FixtureAPITestCase): "archive_hash": "n" * 32, } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)}), request, @@ -824,7 +875,7 @@ class TestModelAPI(FixtureAPITestCase): "archive_hash": "n" * 32, } - with self.assertNumQueries(9): + with self.assertNumQueries(6): response = self.client.patch( reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)}), request, @@ -860,7 +911,7 @@ class TestModelAPI(FixtureAPITestCase): self.model_version3.parent = self.model_version2 self.model_version3.save() - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.patch( reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)}), {"parent": None}, @@ -886,17 +937,14 @@ class TestModelAPI(FixtureAPITestCase): self.model_version3.refresh_from_db() self.assertIsNone(self.model_version3.parent_id) - @patch("arkindex.project.aws.s3.Object") - @patch("arkindex.project.aws.S3FileMixin.exists") - def test_update_model_version_requires_contributor(self, exists, s3_object): + @patch("arkindex.training.api.get_max_level", return_value=None) + def test_update_model_version_requires_contributor(self, get_max_level_mock): """ Can't update a model version with guest access rights to the model """ - s3_object().content_length = self.model_version3.size - s3_object().e_tag = self.model_version3.archive_hash - exists.return_value = True self.client.force_login(self.user3) - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.put( reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version3.id)}), { @@ -905,12 +953,15 @@ class TestModelAPI(FixtureAPITestCase): "size": 8, "state": ModelVersionState.Available.value, "description": "other description", - "configuration": {"hi": "Who am I ?"}, + "configuration": {"hi": "Who am I?"}, }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You need a Contributor access to the model to update this version."}) + self.assertEqual(get_max_level_mock.call_count, 1) + self.assertEqual(get_max_level_mock.call_args, call(self.user3, self.model2)) @patch("arkindex.project.aws.s3.Object") @patch("arkindex.project.aws.S3FileMixin.exists") @@ -922,7 +973,7 @@ class TestModelAPI(FixtureAPITestCase): self.model1.save() self.client.force_login(self.user1) - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.put( reverse("api:model-version-retrieve", kwargs={"pk": str(self.model_version1.id)}), { @@ -939,21 +990,23 @@ class TestModelAPI(FixtureAPITestCase): self.assertDictEqual(response.json(), {"model": ["This model is archived."]}) - @patch("arkindex.project.aws.S3FileMixin.exists") - def test_validate_model_version_requires_contributor(self, exists): + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_validate_model_version_requires_contributor(self, has_access_mock): self.client.force_login(self.user3) - exists.return_value = False - with self.assertNumQueries(6): + + with self.assertNumQueries(3): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version2.id)}), {"archive_hash": "x" * 32, "hash": "y" * 32, "size": 32}, format="json" ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), { "detail": "You need a Contributor access to the model to validate this version." }) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user3, self.model1, Role.Contributor.value, skip_public=False)) @patch("arkindex.project.aws.S3FileMixin.exists") def test_validate_model_version_archived(self, exists): @@ -962,7 +1015,7 @@ class TestModelAPI(FixtureAPITestCase): self.client.force_login(self.user1) exists.return_value = False - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {"archive_hash": "x" * 32, "hash": "y" * 32, "size": 32}, @@ -976,7 +1029,7 @@ class TestModelAPI(FixtureAPITestCase): def test_validate_model_version_required_fields(self, exists): self.client.force_login(self.user1) exists.return_value = False - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {}, @@ -996,7 +1049,7 @@ class TestModelAPI(FixtureAPITestCase): """ self.client.force_login(self.user1) exists.return_value = False - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {"archive_hash": "d" * 32, "hash": "e" * 32, "size": 32}, @@ -1016,7 +1069,7 @@ class TestModelAPI(FixtureAPITestCase): """ self.client.force_login(self.user1) s3_url_mock.return_value = "http://s3/archive_url" - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {"archive_hash": "d" * 32, "hash": str(self.model_version5.hash), "size": 32}, @@ -1050,7 +1103,7 @@ class TestModelAPI(FixtureAPITestCase): exists.return_value = True s3_object().content_length = 31 self.client.force_login(self.user1) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {"archive_hash": "d" * 32, "hash": "e" * 32, "size": 32}, @@ -1072,7 +1125,7 @@ class TestModelAPI(FixtureAPITestCase): s3_object().content_length = 32 s3_object().e_tag = "a" * 32 self.client.force_login(self.user1) - with self.assertNumQueries(8): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-version-validate", kwargs={"pk": str(self.model_version1.id)}), {"archive_hash": "d" * 32, "hash": "e" * 32, "size": 32}, @@ -1151,23 +1204,28 @@ class TestModelAPI(FixtureAPITestCase): request = { "name": self.model1.name, } - with self.assertNumQueries(6): + with self.assertNumQueries(3): response = self.client.post(reverse("api:models"), request) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertDictEqual(response.json(), {"id": str(self.model1.id), "name": "A model with this name already exists"}) - def test_create_model_name_taken_no_access(self): - """Raises a 403 with no additional information when creating a model with a name that is already used for another model + @patch("arkindex.project.mixins.has_access", return_value=False) + def test_create_model_name_taken_no_access(self, has_access_mock): + """ + Raises a 403 with no additional information when creating a model with a name that is already used for another model but without access rights on this model """ self.client.force_login(self.user3) - request = { - "name": self.model1.name, - } - with self.assertNumQueries(6): - response = self.client.post(reverse("api:models"), request) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + with self.assertNumQueries(3): + response = self.client.post(reverse("api:models"), { + "name": self.model1.name, + }) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) + self.assertEqual(has_access_mock.call_count, 1) + self.assertEqual(has_access_mock.call_args, call(self.user3, self.model1, Role.Guest.value, skip_public=False)) def test_list_models_requires_verified(self): user = User.objects.create(display_name="Not Verified", verified_email=False) @@ -1181,38 +1239,13 @@ class TestModelAPI(FixtureAPITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - def test_list_models_low_access(self): - """User 3 has only access to Model2 with a guest access. - He only sees model_version3 of Model2 since it's the only one that - has a set tag and is in Available state. - """ - self.client.force_login(self.user3) - with self.assertNumQueries(7): - response = self.client.get(reverse("api:models")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - models = response.json()["results"] - - self.assertListEqual(models, [ - { - "id": str(self.model2.id), - "created": self.model2.created.isoformat().replace("+00:00", "Z"), - "updated": self.model2.updated.isoformat().replace("+00:00", "Z"), - "name": "Second Model", - "description": "", - "rights": ["read"], - "archived": False, - } - ]) - - def test_list_models_contrib_access(self): - """User 2 has contributor access to Model1 and Model2. - He has contributor access on both that's why he sees all related versions. - Models list is ordered by name, first Model1 (named 'First Model') then Model2 (named 'Second Model'). - """ + def test_list(self): self.client.force_login(self.user2) - with self.assertNumQueries(8): + + with self.assertNumQueries(4): response = self.client.get(reverse("api:models")) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + models = response.json()["results"] self.assertListEqual(models, [ @@ -1222,7 +1255,7 @@ class TestModelAPI(FixtureAPITestCase): "updated": self.model1.updated.isoformat().replace("+00:00", "Z"), "name": "First Model", "description": "first", - "rights": ["read", "write"], + "rights": ["read", "write", "admin"], "archived": False, }, { @@ -1233,14 +1266,23 @@ class TestModelAPI(FixtureAPITestCase): "description": "", "rights": ["read", "write", "admin"], "archived": False, - } + }, + { + "id": str(self.model3.id), + "created": self.model3.created.isoformat().replace("+00:00", "Z"), + "updated": self.model3.updated.isoformat().replace("+00:00", "Z"), + "name": "Third Model", + "description": "", + "rights": ["read", "write", "admin"], + "archived": False, + }, ]) def test_list_models_filter_name(self): """User 2 has access to both Models, use search parameter name=Second returns only Model2 """ self.client.force_login(self.user2) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:models"), {"name": "second"}) self.assertEqual(response.status_code, status.HTTP_200_OK) models = response.json()["results"] @@ -1259,7 +1301,7 @@ class TestModelAPI(FixtureAPITestCase): def test_list_models_filter_compatible_worker(self): self.client.force_login(self.user2) - with self.assertNumQueries(7): + with self.assertNumQueries(4): response = self.client.get(reverse("api:models"), {"compatible_worker": str(self.worker.id)}) self.assertEqual(response.status_code, status.HTTP_200_OK) models = response.json()["results"] @@ -1289,7 +1331,7 @@ class TestModelAPI(FixtureAPITestCase): "updated": self.model1.updated.isoformat().replace("+00:00", "Z"), "name": "First Model", "description": "first", - "rights": ["read", "write"], + "rights": ["read", "write", "admin"], "archived": True, } ]), @@ -1302,19 +1344,28 @@ class TestModelAPI(FixtureAPITestCase): "description": "", "rights": ["read", "write", "admin"], "archived": False, - } + }, + { + "id": str(self.model3.id), + "created": self.model3.created.isoformat().replace("+00:00", "Z"), + "updated": self.model3.updated.isoformat().replace("+00:00", "Z"), + "name": "Third Model", + "description": "", + "rights": ["read", "write", "admin"], + "archived": False, + }, ]), ] for archived, expected_results in cases: - with self.subTest(archived=archived), self.assertNumQueries(7): + with self.subTest(archived=archived), self.assertNumQueries(4): response = self.client.get(reverse("api:models"), {"archived": archived}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()["results"], expected_results) def test_list_models_filter_compatible_worker_doesnt_exist(self): self.client.force_login(self.user2) - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:models"), {"compatible_worker": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}) self.assertEqual(response.status_code, status.HTTP_200_OK) models = response.json()["results"] @@ -1324,7 +1375,7 @@ class TestModelAPI(FixtureAPITestCase): def test_list_models_filter_compatible_worker_no_models(self): self.client.force_login(self.user2) dla_worker = Worker.objects.get(slug="dla") - with self.assertNumQueries(5): + with self.assertNumQueries(3): response = self.client.get(reverse("api:models"), {"compatible_worker": str(dla_worker.id)}) self.assertEqual(response.status_code, status.HTTP_200_OK) models = response.json()["results"] diff --git a/arkindex/training/tests/test_model_compatible_worker.py b/arkindex/training/tests/test_model_compatible_worker.py index f163dc0e0c..20a19ff5b7 100644 --- a/arkindex/training/tests/test_model_compatible_worker.py +++ b/arkindex/training/tests/test_model_compatible_worker.py @@ -1,9 +1,10 @@ from datetime import datetime, timezone +from unittest.mock import call, patch from django.urls import reverse from rest_framework import status -from arkindex.process.models import Worker +from arkindex.process.models import Repository, Worker from arkindex.project.tests import FixtureAPITestCase from arkindex.training.models import Model from arkindex.users.models import Role @@ -63,7 +64,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): def test_create_model_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.worker1.id), @@ -76,11 +77,18 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "model": [f'Invalid pk "{self.worker1.id}" - object does not exist.'], }) - def test_create_requires_model_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_create_requires_model_contributor(self, filter_rights_mock): self.model1.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + filter_rights_mock.side_effect = [ + Model.objects.none(), + Worker.objects.all(), + Repository.objects.all(), + ] + + with self.assertNumQueries(3): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -93,27 +101,16 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "model": [f'Invalid pk "{self.model1.id}" - object does not exist.'], }) - def test_create_ignores_model_public(self): - self.client.force_login(self.user) - - with self.assertNumQueries(8): - response = self.client.post( - reverse("api:model-compatible-worker-manage", kwargs={ - # User has no access to model2 and model2 is public - "model": str(self.model2.id), - "worker": str(self.worker2.id), - }) - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model": [f'Invalid pk "{self.model2.id}" - object does not exist.'], - }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Contributor.value), + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) def test_create_worker_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -126,11 +123,18 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "worker": [f'Invalid pk "{self.model1.id}" - object does not exist.'], }) - def test_create_requires_worker_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_create_requires_worker_contributor(self, filter_rights_mock): self.worker2.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + filter_rights_mock.side_effect = [ + Model.objects.all(), + Worker.objects.none(), + Repository.objects.none(), + ] + + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -143,10 +147,16 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "worker": [f'Invalid pk "{self.worker2.id}" - object does not exist.'], }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Contributor.value), + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + def test_create_nothing_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.worker2.id), @@ -164,12 +174,10 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.client.force_login(self.user) # 2 queries to retrieve the user and session - # 4 queries for the various content types for memberships, - # which are normally cached between requests but reset between each unit test # 2 queries to retrieve the model and worker # 1 query to check that the relationship does not already exist # 1 query to insert - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -199,7 +207,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.worker1.save() self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -255,7 +263,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.model1.compatible_workers.add(self.worker2) self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -283,7 +291,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.model1.save() self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.post( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -341,7 +349,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): def test_destroy_model_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.worker2.id), @@ -354,11 +362,18 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "model": [f'Invalid pk "{self.worker2.id}" - object does not exist.'], }) - def test_destroy_requires_model_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_destroy_requires_model_contributor(self, filter_rights_mock): self.model1.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + filter_rights_mock.side_effect = [ + Model.objects.none(), + Worker.objects.all(), + Repository.objects.all(), + ] + + with self.assertNumQueries(3): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -371,27 +386,16 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "model": [f'Invalid pk "{self.model1.id}" - object does not exist.'], }) - def test_destroy_ignores_model_public(self): - self.client.force_login(self.user) - - with self.assertNumQueries(8): - response = self.client.delete( - reverse("api:model-compatible-worker-manage", kwargs={ - # User has no access to model2 and model2 is public - "model": str(self.model2.id), - "worker": str(self.worker2.id), - }) - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model": [f'Invalid pk "{self.model2.id}" - object does not exist.'], - }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Contributor.value), + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) def test_destroy_worker_not_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -404,11 +408,18 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "worker": [f'Invalid pk "{self.model1.id}" - object does not exist.'], }) - def test_destroy_requires_worker_contributor(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights") + def test_destroy_requires_worker_contributor(self, filter_rights_mock): self.worker2.memberships.filter(user=self.user).update(level=Role.Guest.value) self.client.force_login(self.user) - with self.assertNumQueries(7): + filter_rights_mock.side_effect = [ + Model.objects.all(), + Worker.objects.none(), + Repository.objects.none(), + ] + + with self.assertNumQueries(4): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -421,10 +432,16 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): "worker": [f'Invalid pk "{self.worker2.id}" - object does not exist.'], }) + self.assertListEqual(filter_rights_mock.call_args_list, [ + call(self.user, Model, Role.Contributor.value), + call(self.user, Worker, Role.Contributor.value), + call(self.user, Repository, Role.Contributor.value), + ]) + def test_destroy_nothing_found(self): self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.worker2.id), @@ -444,12 +461,10 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.model1.compatible_workers.set([self.worker1, self.worker2]) # 2 queries to retrieve the user and session - # 4 queries for the various content types for memberships, - # which are normally cached between requests but reset between each unit test # 2 queries to retrieve the model and worker # 1 query to retrieve the existing relationship # 1 query to delete - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -476,7 +491,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.model1.compatible_workers.add(self.worker2) self.client.force_login(self.user) - with self.assertNumQueries(10): + with self.assertNumQueries(6): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -524,7 +539,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): def test_destroy_does_not_exist(self): self.client.force_login(self.user) - with self.assertNumQueries(9): + with self.assertNumQueries(5): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), @@ -553,7 +568,7 @@ class TestModelCompatibleWorkerManage(FixtureAPITestCase): self.model1.compatible_workers.add(self.worker2) self.client.force_login(self.user) - with self.assertNumQueries(8): + with self.assertNumQueries(4): response = self.client.delete( reverse("api:model-compatible-worker-manage", kwargs={ "model": str(self.model1.id), diff --git a/arkindex/users/admin.py b/arkindex/users/admin.py index 3d9d1a9129..a50a388cf8 100644 --- a/arkindex/users/admin.py +++ b/arkindex/users/admin.py @@ -3,11 +3,10 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.forms import ReadOnlyPasswordHashField from django.contrib.auth.models import Group as BaseGroup -from django.contrib.contenttypes.admin import GenericTabularInline from django.db.models.functions import Collate from enumfields.admin import EnumFieldListFilter -from arkindex.users.models import Group, Right, User, UserScope +from arkindex.users.models import User, UserScope class UserCreationForm(forms.ModelForm): @@ -95,36 +94,7 @@ class UserScopeAdmin(admin.ModelAdmin): list_filter = [("scope", EnumFieldListFilter), ] -class GroupMembershipInline(GenericTabularInline): - ct_fk_field = "content_id" - model = Right - fields = ("group", "level") - extra = 1 - - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.filter(user__isnull=True) - - -class UserMembershipInline(GenericTabularInline): - ct_fk_field = "content_id" - model = Right - fields = ("user", "level") - extra = 1 - - def get_queryset(self, request): - qs = super().get_queryset(request) - return qs.filter(group__isnull=True) - - -class GroupAdmin(admin.ModelAdmin): - list_display = ("id", "name", "public") - inlines = (UserMembershipInline, ) - - admin.site.register(User, UserAdmin) -# Register the custom GroupAdmin -admin.site.register(Group, GroupAdmin) -# and hide base GroupAdmin form contrib.auth -admin.site.unregister(BaseGroup) admin.site.register(UserScope, UserScopeAdmin) +# Remove the admin page for the default Django groups +admin.site.unregister(BaseGroup) diff --git a/arkindex/users/allow_all.py b/arkindex/users/allow_all.py new file mode 100644 index 0000000000..2704635a32 --- /dev/null +++ b/arkindex/users/allow_all.py @@ -0,0 +1,28 @@ +from typing import Optional + +from django.db.models import IntegerField, Value + +from arkindex.users.models import Role, User + + +def has_access(user: User, instance, level: int, skip_public: bool = False) -> bool: + """ + Check if the user has access to a generic instance with a minimum level + If skip_public parameter is set to true, exclude rights on public instances + """ + return True + + +def filter_rights(user: User, model, level: int): + """ + Return a generic queryset of objects with access rights for this user. + Level filtering parameter should be an integer between 1 and 100. + """ + return model.objects.annotate(max_level=Value(Role.Admin.value, IntegerField())) + + +def get_max_level(user: User, instance) -> Optional[int]: + """ + Returns the maximum access level on a given model instance + """ + return Role.Admin.value diff --git a/arkindex/users/api.py b/arkindex/users/api.py index 656111cd18..5c4b9c40df 100644 --- a/arkindex/users/api.py +++ b/arkindex/users/api.py @@ -1,15 +1,11 @@ import logging import urllib.parse -from uuid import UUID from django.conf import settings from django.contrib.auth import login, logout from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.tokens import default_token_generator -from django.contrib.contenttypes.models import ContentType from django.core.mail import send_mail -from django.db.models import Count, Prefetch, Q -from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string from django.urls import reverse from django.utils.http import urlsafe_base64_encode @@ -19,40 +15,31 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import serializers, status from rest_framework.exceptions import AuthenticationFailed, NotFound, PermissionDenied, ValidationError -from rest_framework.generics import ( - CreateAPIView, - ListAPIView, - ListCreateAPIView, - RetrieveDestroyAPIView, - RetrieveUpdateDestroyAPIView, -) -from rest_framework.permissions import SAFE_METHODS +from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView from rest_framework.response import Response from rest_framework.views import APIView from rq.job import JobStatus from arkindex.documents.models import Corpus -from arkindex.process.models import Worker -from arkindex.project.mixins import ACLMixin, WorkerACLMixin from arkindex.project.permissions import IsAuthenticatedOrReadOnly, IsVerified -from arkindex.users.models import Group, Right, Role, Scope, User, UserScope +from arkindex.users.models import Group, Role, Scope, User, UserScope from arkindex.users.serializers import ( EmailLoginSerializer, - GenericMembershipSerializer, - GroupSerializer, JobSerializer, - MemberGroupSerializer, - MembershipCreateSerializer, - MembershipSerializer, NewUserSerializer, PasswordResetConfirmSerializer, PasswordResetSerializer, UserSerializer, ) -from arkindex.users.utils import RightContent, get_max_level logger = logging.getLogger(__name__) +# Process tasks running in RQ are hidden from user jobs +VISIBLE_QUEUES = [ + q for q in QUEUES.keys() + if q != "tasks" +] + @extend_schema(tags=["users"]) @extend_schema_view( @@ -290,23 +277,6 @@ class PasswordResetConfirm(CreateAPIView): serializer_class = PasswordResetConfirmSerializer -@extend_schema(tags=["users"]) -class GroupsList(ListCreateAPIView): - """ - List existing groups either if they are public or the request user is a member. - A POST request allow to create a new group. - """ - serializer_class = GroupSerializer - permission_classes = (IsVerified, ) - - def get_queryset(self): - # List public groups and ones to which user belongs - return Group.objects \ - .annotate(members_count=Count("users")) \ - .filter(Q(public=True) | Q(users__in=[self.request.user])) \ - .order_by("name", "id") - - @extend_schema(tags=["jobs"]) class JobList(ListAPIView): """ @@ -340,7 +310,7 @@ class JobRetrieve(RetrieveDestroyAPIView): serializer_class = JobSerializer def get_object(self): - for queue_name in QUEUES.keys(): + for queue_name in VISIBLE_QUEUES: job = get_queue(queue_name).fetch_job(str(self.kwargs["pk"])) if not job: continue @@ -353,287 +323,3 @@ class JobRetrieve(RetrieveDestroyAPIView): if instance.get_status(refresh=False) == JobStatus.STARTED: raise ValidationError(["Cannot delete a running job."]) instance.delete() - - -@extend_schema(tags=["users"]) -class GroupsCreate(CreateAPIView): - """ - Create a new group. The request user will be added as a member of this group. - """ - serializer_class = GroupSerializer - permission_classes = (IsVerified, ) - # For OpenAPI type discovery - queryset = Group.objects.none() - - -@extend_schema(tags=["users"]) -@extend_schema_view( - patch=extend_schema( - description="Partially update details of a group. " - "Requires to have `admin` privileges on this group." - ), - put=extend_schema( - description="Update details of a group. " - "Requires to have `admin` privileges on this group." - ), - delete=extend_schema( - description="Delete a group. " - "Requires to have `admin` privileges on this group." - ) -) -class GroupDetails(RetrieveUpdateDestroyAPIView): - """ - Retrieve details about a specific group - """ - serializer_class = GroupSerializer - permission_classes = (IsVerified, ) - # For OpenAPI type discovery - queryset = Group.objects.none() - - def get_membership(self, group): - try: - return group.memberships.get(user_id=self.request.user.id) - except Right.DoesNotExist: - raise PermissionDenied(detail="Only members of a group can retrieve their details") - - def get_object(self): - if not hasattr(self, "_group"): - self._group = super().get_object() - - if self.request.user.is_admin: - self._group.level = Role.Admin.value - else: - # Retrieve the membership to know the privilege level for this user - if not hasattr(self, "_member"): - self._member = self.get_membership(self._group) - # Add request member level to the group - self._group.level = self._member.level - - return self._group - - def check_object_permissions(self, request, obj): - if request.user.is_admin: - return - if not hasattr(self, "_member"): - self._member = self.get_membership(obj) - # Check the user has the right to delete or update a group - super().check_object_permissions(request, obj) - if request.method not in SAFE_METHODS and self._member.level < Role.Admin.value: - raise PermissionDenied(detail="Only members with an admin privilege may update or delete this group.") - - def get_queryset(self): - # Superusers have access to all groups - if self.request.user.is_authenticated and self.request.user.is_admin: - return Group.objects.all() \ - .annotate(members_count=Count("memberships")) - # Filter groups that are public or for which the user is a member - return Group.objects \ - .annotate(members_count=Count("memberships")) \ - .filter( - Q(public=True) - | Q(memberships__user=self.request.user) - ) - - -@extend_schema(tags=["users"]) -class UserMemberships(ListAPIView): - """ - List groups to which the request user belongs with its privileges on each group - """ - serializer_class = MemberGroupSerializer - permission_classes = (IsVerified, ) - - def get_queryset(self): - - def _build_fake_right(group): - # Serialize a fake memberships for admin user with no needs for ID and level - admin_membership = Right( - content_object=group, - user=self.request.user, - level=None, - ) - admin_membership.id = None - admin_membership.members_count = group.members_count - return admin_membership - - if self.request.user.is_authenticated and self.request.user.is_admin: - # Allow super admins to access all groups as if they had an admin access level - groups = Group.objects.all() \ - .annotate(members_count=Count("memberships")) \ - .order_by("name", "id") - return [_build_fake_right(group) for group in groups] - # Annotate rights with the group member count as Prefetch is not available with the Generic FK - return self.request.user.rights \ - .prefetch_related("content_object") \ - .filter(content_type=ContentType.objects.get_for_model(Group)) \ - .annotate(members_count=Count("group_target__memberships")) \ - .order_by("group_target__name", "group_target__id") - - -@extend_schema(tags=["users"]) -@extend_schema_view( - patch=extend_schema( - description="Partially update a generic membership." - ), - put=extend_schema( - description="Update a generic membership." - ), - delete=extend_schema( - description="Delete a generic membership." - ) -) -class MembershipDetails(ACLMixin, RetrieveUpdateDestroyAPIView): - """ - Retrieve details of a generic membership - """ - serializer_class = MembershipSerializer - permission_classes = (IsVerified, ) - # Perform access checks with the permissions - queryset = Right.objects.select_related("content_type").all() - - def check_object_permissions(self, request, membership): - super().check_object_permissions(request, membership) - - # Retrieve access level - access_level = None - if isinstance(membership.content_object, Worker): - # Use WorkerACLMixin method as the worker access level may be inherited from its repository - access_level = WorkerACLMixin.get_max_level(self, membership.content_object) - else: - access_level = get_max_level(self.request.user, membership.content_object) - - # Check the access level of the request user on the content object - if not access_level or access_level < Role.Guest.value: - raise NotFound - - # Allow any guest to retrieve information - if self.request.method in SAFE_METHODS: - return - - # Allow a user to remove its own membership - if self.request.method == "DELETE" and membership.user_id == self.request.user.id: - return - - # Only allow admins to edit the access level of a membership - if access_level < Role.Admin.value: - raise PermissionDenied( - detail="Only admins of the target membership group can perform this action." - ) - - def perform_destroy(self, instance): - # At least one admin member must exist except for workers which depends on their repositories - if ( - instance.content_type != ContentType.objects.get_for_model(Worker) - and instance.level >= Role.Admin.value - and instance.content_object.memberships.using("default").filter(level__gte=Role.Admin.value).count() < 2 - ): - raise ValidationError({"detail": ["Removing all memberships with an admin privilege is not possible."]}) - return super().perform_destroy(instance) - - -MEMBERSHIPS_TYPE_FILTERS = ["user", "group"] - - -@extend_schema( - parameters=[ - OpenApiParameter( - content.name, - type=OpenApiTypes.UUID, - description=f"List user members for a {content}", - ) - for content in RightContent - ] + [ - OpenApiParameter( - "type", - description="Filter memberships by owner type", - enum=MEMBERSHIPS_TYPE_FILTERS, - ) - ], - tags=["users"] -) -class GenericMembershipsList(WorkerACLMixin, ListAPIView): - """ - List memberships for a specific content with their privileges. - Target of the right must be defined as a query parameter. - """ - permission_classes = (IsVerified, ) - serializer_class = GenericMembershipSerializer - - def get_content_object(self): - """ - Retrieve a generic content based on query parameters. - This method does not perform any permission check. - """ - allowed_contents = [c.name for c in RightContent] - content_keys = [key for key in self.request.query_params if key in allowed_contents] - # Assert exactly one of the content object parameter has been set - if len(content_keys) != 1: - raise ValidationError({ - "__all__": [ - "Exactly one of those query parameters must be defined: " - f"{', '.join(allowed_contents)}." - ] - }) - - key, = content_keys - content_model = RightContent[key].value - content_id = self.request.query_params[key] - try: - content_uuid = UUID(content_id) - except (AttributeError, ValueError): - raise ValidationError({key: [f"'{content_id}' is not a valid UUID."]}) - - # Use default database in case the content object has just been created - return get_object_or_404(content_model.objects.using("default"), id=content_uuid) - - def get_queryset(self): - content_object = self.get_content_object() - - # Determine the user right on the generic content - has_access = None - if isinstance(content_object, Worker): - # Workers rights may be determined by their repository - has_access = self.has_worker_access(content_object, Role.Guest.value) - else: - access_params = {} - if isinstance(content_object, Group): - # A user may not be able to list group members if he is not a member - access_params["skip_public"] = True - has_access = self.has_access(content_object, Role.Guest.value, **access_params) - - # Ensure the user has a guest access to the content - if not has_access: - raise PermissionDenied( - detail="You do not have the required access level to list members for this content." - ) - - # Avoid a stale read when adding/deletig a member - qs = content_object.memberships \ - .using("default") \ - .prefetch_related( - "user", - Prefetch("group", queryset=Group.objects.annotate(members_count=Count("memberships"))) - ) \ - .order_by("user__display_name", "group__name", "id") - - # Handle a simple owner type filter - type_filter = self.request.query_params.get("type") - if type_filter and type_filter in MEMBERSHIPS_TYPE_FILTERS: - type_filter = {f"{type_filter}__isnull": False} - qs = qs.filter(**type_filter) - - return qs - - -@extend_schema_view( - post=extend_schema( - operation_id="CreateMembership", - tags=["users"], - ) -) -class GenericMembershipCreate(CreateAPIView): - """ - Create a new generic membership. - """ - permission_classes = (IsVerified, ) - serializer_class = MembershipCreateSerializer diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py index 42fb755c08..705ef7d16f 100644 --- a/arkindex/users/serializers.py +++ b/arkindex/users/serializers.py @@ -1,18 +1,12 @@ from django.conf import settings from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import default_token_generator -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.db import transaction from django.utils.http import urlsafe_base64_decode from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers -from rest_framework.exceptions import PermissionDenied -from arkindex.process.models import Worker -from arkindex.project.mixins import WorkerACLMixin -from arkindex.users.models import Group, Right, Role, User -from arkindex.users.utils import RightContent, get_max_level +from arkindex.users.models import User def validate_user_password(user, data): @@ -171,197 +165,3 @@ class JobSerializer(serializers.Serializer): but the enum is just a plain object and not an Enum for Py2 compatibility. """ return instance.get_status(refresh=False) - - -class SimpleGroupSerializer(serializers.ModelSerializer): - """ - A light group serializer with its members count - """ - members_count = serializers.IntegerField(default=None, read_only=True) - - class Meta: - model = Group - read_only_fields = ("id", "members_count") - fields = ( - "id", - "name", - "public", - "members_count" - ) - - -class GroupSerializer(SimpleGroupSerializer): - """ - A group serializer with the request member level - """ - level = serializers.IntegerField(default=None, read_only=True) - - class Meta(SimpleGroupSerializer.Meta): - fields = SimpleGroupSerializer.Meta.fields + ("level", ) - - @transaction.atomic - def create(self, validated_data): - group = super().create(validated_data) - # Associate the creator to the group - group.add_member(self.context["request"].user, Role.Admin.value) - # Manually set fields required by the serializer - group.members_count = 1 - group.level = Role.Admin.value - return group - - -class MembershipSerializer(serializers.ModelSerializer): - """ - Simple serializer for a membership - """ - - class Meta: - model = Right - fields = ( - "id", - "level", - ) - - def validate(self, data): - data = super().validate(data) - level_field = data.get("level") - if not self.instance or not level_field: - return data - # Assert an update will not remove the last group admin except for a worker - if (self.instance.level >= Role.Admin.value - and level_field < Role.Admin.value - and self.instance.content_type != ContentType.objects.get_for_model(Worker) - and self.instance.content_object.memberships.filter(level__gte=Role.Admin.value).count() < 2): - raise ValidationError({"level": ["Removing all memberships with an admin privilege is not possible."]}) - return data - - -class MemberGroupSerializer(MembershipSerializer): - """ - Serialize a group for a specific member with its privilege level - """ - group = SimpleGroupSerializer(source="content_object") - - class Meta(MembershipSerializer.Meta): - fields = MembershipSerializer.Meta.fields + ("group", ) - - def __init__(self, instance=None, *args, **kwargs): - if instance and isinstance(instance, list): - # Annotate each target group with the memberships count - for right in instance: - right.content_object.members_count = right.members_count - super().__init__(instance, *args, **kwargs) - - -class GenericMembershipSerializer(MembershipSerializer): - """ - Serialize a generic membership - """ - user = SimpleUserSerializer(allow_null=True) - group = SimpleGroupSerializer(allow_null=True) - - class Meta(MembershipSerializer.Meta): - fields = MembershipSerializer.Meta.fields + ("user", "group") - - -class MembershipContentType(serializers.ChoiceField): - """ - Specific field to handle a membership generic content - """ - - def __init__(self, *args, **kwargs): - choices = [c.name for c in RightContent] - super().__init__(choices, *args, **kwargs) - - def to_representation(self, obj): - if not isinstance(obj, ContentType): - return None - model = obj.model_class() - if model not in [c.value for c in RightContent]: - return None - return RightContent(model).name - - def to_internal_value(self, data): - try: - return ContentType.objects.get_for_model(RightContent[data].value) - except Exception: - raise ValidationError([f"'{data}' is not a valid content type."]) - - -class MembershipCreateSerializer(MembershipSerializer): - """ - Create a new generic membership - """ - user_email = serializers.EmailField(source="user.email", required=False, allow_null=True) - group_id = serializers.UUIDField(required=False, allow_null=True) - content_type = MembershipContentType() - content_id = serializers.UUIDField(help_text="The ID of the right target.") - - class Meta(MembershipSerializer.Meta): - fields = ( - "id", - "level", - "user_email", - "group_id", - "content_type", - "content_id" - ) - - def validate(self, data): - data = super().validate(data) - - user_email = data.pop("user", {"email": None})["email"] - group_id = data.pop("group_id", None) - - # Assert one of user/group identifier is defined - if not (user_email is None) ^ (group_id is None): - raise ValidationError({"detail": ["Exactly one of those fields must be defined: user_email, group_id"]}) - - # Retrieve the right generic content object - content_type = data.pop("content_type") - content_model = content_type.model_class() - content_id = data.pop("content_id") - try: - data["content_object"] = content_model.objects.get(id=content_id) - except content_model.DoesNotExist: - raise ValidationError({"content_id": [f"{content_type.name.capitalize()} with this ID could not be found."]}) - - # This is required to use workers ACL mixin out of an API view - request_user = self.context["request"].user - - # Retrieve access level - access_level = None - if content_model is Worker: - # Use WorkerACLMixin method as the worker access level may be inherited from its repository - access_level = WorkerACLMixin(user=request_user).get_max_level(data["content_object"]) - else: - access_level = get_max_level(request_user, data["content_object"]) - # Assert the request user a an admin access to the content object - if not access_level: - raise PermissionDenied(detail="You are not a member of the target content object.") - if not access_level or access_level < Role.Admin.value: - raise ValidationError({"content_id": ["Only members with an admin privilege are allowed to add other members."]}) - - # Special restriction for group memberships - if content_model is Group and group_id is not None: - raise ValidationError({"group_id": ["It is not possible to create a membership between two groups."]}) - - # Retrieve owner of the right - owner_data = {} - if user_email: - try: - owner_data["user"] = User.objects.get(email=user_email) - except User.DoesNotExist: - raise ValidationError({"user_email": ["No user matching this email could be found."]}) - elif group_id: - try: - owner_data["group"] = Group.objects.get(id=group_id) - except Group.DoesNotExist: - raise ValidationError({"group_id": ["No group matching this id could be found."]}) - - # Handle existing memberships - if data["content_object"].memberships.filter(**owner_data).exists(): - raise ValidationError({"__all__": ["This membership already exists."]}) - - data.update(owner_data) - return data diff --git a/arkindex/users/tests/test_generic_memberships.py b/arkindex/users/tests/test_generic_memberships.py deleted file mode 100644 index 9371345afe..0000000000 --- a/arkindex/users/tests/test_generic_memberships.py +++ /dev/null @@ -1,1078 +0,0 @@ -import uuid - -from django.db import IntegrityError -from django.urls import reverse -from rest_framework import status - -from arkindex.documents.models import Corpus -from arkindex.process.models import Repository, Worker -from arkindex.project.tests import FixtureAPITestCase -from arkindex.users.models import Group, Right, Role, User - - -class TestMembership(FixtureAPITestCase): - """ - Test generic rights management methods - """ - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.unverified = User.objects.create_user("user@address.com", "P4$5w0Rd") - cls.admin = User.objects.create_user("admin@address.com", "P4$5w0Rd") - cls.non_admin = User.objects.filter( - rights__group_target=cls.group, - rights__level=Role.Contributor.value - ).first() - cls.non_admin.verified_email = True - cls.non_admin.save() - - cls.admin_group = Group.objects.create(name="Admin group", public=True) - cls.admin_group.add_member(user=cls.admin, level=Role.Admin.value) - - cls.membership = cls.group.memberships.get(user=cls.user) - cls.admin_membership = cls.admin_group.memberships.get(user=cls.admin) - - def test_verified_user_group_create(self): - """ - Anonymous and non verified users should not be able to create a group - """ - self.client.force_login(self.unverified) - with self.assertNumQueries(2): - response = self.client.post(reverse("api:groups-create"), {"name": "new group"}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.client.logout() - with self.assertNumQueries(0): - response = self.client.post(reverse("api:groups-create"), {"name": "new group"}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_create_group_wrong_fields(self): - checks = ( - ({"name": 100 * "A"}, {"name": ["Ensure this field has no more than 64 characters."]}), - ({"name": ""}, {"name": ["This field may not be blank."]}) - ) - self.client.force_login(self.user) - for payload, error in checks: - response = self.client.post(reverse("api:groups-create"), payload) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json(), error) - - def test_create_group(self): - """ - A user creating a group is granted a membership with the maximum privileges level - """ - self.client.force_login(self.user) - with self.assertNumQueries(7): - response = self.client.post(reverse("api:groups-create"), {"name": "new group", "public": True}) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - group = Group.objects.get(name="new group") - self.assertEqual(response.json(), { - "id": str(group.id), - "name": "new group", - "public": True, - "members_count": 1, - "level": 100 - }) - self.assertCountEqual( - group.memberships.values_list("user", "level"), - [(self.user.id, Role.Admin.value)] - ) - - def test_crate_group_duplicated_name(self): - """ - Assert multiple groups can have a similar name - """ - self.client.force_login(self.user) - with self.assertNumQueries(7): - response = self.client.post(reverse("api:groups-create"), {"name": self.group.name}) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_list_members_requires_member_user(self): - """ - Only users that belong to a group have the ability to list members, otherwise we return a 403 - """ - private_group = Group.objects.create(name="Admin group", public=False) - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:memberships-list"), {"group": str(private_group.id)}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_members_superuser(self): - """ - A superadmin is able to list members of any group - """ - private_group = Group.objects.create(name="Private group", public=False) - member = private_group.memberships.create(user=self.user, level=Role.Admin.value) - self.client.force_login(self.superuser) - with self.assertNumQueries(6): - response = self.client.get(reverse("api:memberships-list"), {"group": str(private_group.id)}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "count": 1, - "next": None, - "number": 1, - "previous": None, - "results": [{ - "id": str(member.id), - "level": Role.Admin.value, - "user": { - "display_name": self.user.display_name, - "email": self.user.email, - "id": self.user.id, - }, - "group": None, - }] - }) - - def test_list_members_invalid_uuid(self): - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.get(reverse("api:memberships-list"), {"group": "A"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), {"group": ["'A' is not a valid UUID."]}) - - def test_list_members_wrong_parameters(self): - """ - Exactly one of valid content types should be provided as an URL parameter - """ - cases = ( - {}, - {"corpus": str(self.corpus.id), "group": str(self.group.id)}, - {"non_existing_content_type": str(self.corpus.id)} - ) - self.client.force_login(self.user) - for wrong_params in cases: - response = self.client.get(reverse("api:memberships-list"), wrong_params) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "__all__": ["Exactly one of those query parameters must be defined: corpus, repository, group, worker, model, farm."] - }) - - def test_list_members_public_content(self): - """ - Any user may be able to list all members on a public content (except for groups in the test above) - """ - corpus = Corpus.objects.create(name="Public corpus", public=True) - user_right = Right.objects.create(user=self.user, content_object=corpus, level=42) - group_right = Right.objects.create(group=self.group, content_object=corpus, level=77) - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.get(reverse("api:memberships-list"), {"corpus": str(corpus.id)}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "count": 2, - "next": None, - "number": 1, - "previous": None, - "results": [ - { - "id": str(user_right.id), - "level": 42, - "user": { - "display_name": self.user.display_name, - "email": self.user.email, - "id": self.user.id, - }, - "group": None, - }, - { - "id": str(group_right.id), - "level": 77, - "user": None, - "group": { - "id": str(self.group.id), - "members_count": self.group.memberships.count(), - "name": self.group.name, - "public": self.group.public, - }, - } - ] - }) - - def test_list_members_public_group(self): - """ - Any user may not be able to list all members on a public group - This behavior is specific to groups - """ - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:memberships-list"), {"group": str(self.admin_group.id)}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), { - "detail": "You do not have the required access level to list members for this content." - }) - - def test_list_members_level_right(self): - """ - List members of a group with their right corresponding to privileges level - Members are ordered by display name - """ - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.get(reverse("api:memberships-list"), {"group": str(self.group.id)}) - read_member, write_member, admin_member = self.group.memberships.order_by("level") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json(), { - "count": 3, - "next": None, - "number": 1, - "previous": None, - "results": [ - { - "id": str(admin_member.id), - "level": Role.Admin.value, - "user": { - "display_name": "Test user", - "email": admin_member.user.email, - "id": admin_member.user.id, - }, - "group": None, - }, { - "id": str(read_member.id), - "level": read_member.level, - "user": { - "display_name": "Test user read", - "email": read_member.user.email, - "id": read_member.user.id, - }, - "group": None, - }, { - "id": str(write_member.id), - "level": write_member.level, - "user": { - "display_name": "Test user write", - "email": write_member.user.email, - "id": write_member.user.id, - }, - "group": None, - } - ] - }) - - def test_list_members_owner_type_filter(self): - """ - Only list group owners of a specific repository - """ - repo = Repository.objects.create(url="http://repo1") - Right.objects.bulk_create([ - Right(user=self.user, content_object=repo, level=42), - Right(user=self.non_admin, content_object=repo, level=43), - Right(user=self.admin, content_object=repo, level=44), - Right(group=self.group, content_object=repo, level=100), - Right(group=self.admin_group, content_object=repo, level=24), - ]) - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.get(reverse("api:memberships-list"), {"repository": str(repo.id), "type": "group"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.json() - self.assertEqual(data["count"], 2) - self.assertCountEqual( - [membership["group"] for membership in data["results"]], - [ - { - "id": str(self.group.id), - "name": "User group", - "public": False, - "members_count": self.group.memberships.count() - }, { - "id": str(self.admin_group.id), - "name": "Admin group", - "public": True, - "members_count": self.admin_group.memberships.count() - } - ] - ) - - def test_list_worker_members_via_repo_right(self): - """ - A user is able to list members of a worker if he is a member of its repository - """ - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - repo.memberships.create(user=self.user, level=Role.Guest.value) - self.client.force_login(self.user) - with self.assertNumQueries(9): - response = self.client.get(reverse("api:memberships-list"), {"worker": str(worker.id)}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.json() - # A worker may have no member - self.assertEqual(data["count"], 0) - - def test_retrieve_group_wrong_id(self): - self.client.force_login(self.user) - with self.assertNumQueries(4): - response = self.client.get(reverse("api:group-details", kwargs={"pk": str(uuid.uuid4())})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve_group_no_member(self): - """ - A non member of a group may not retrieve its details, even if it is public - """ - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:group-details", kwargs={"pk": str(self.admin_group.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_retrieve_group_details(self): - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:group-details", kwargs={"pk": str(self.group.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(self.group.id), - "members_count": 3, - "name": self.group.name, - "public": self.group.public, - "level": Role.Admin.value - }) - - def test_retrieve_group_superuser(self): - """ - A superuser is allowed to retrieve any group. Its level is always 100 - """ - self.client.force_login(self.superuser) - with self.assertNumQueries(4): - response = self.client.get(reverse("api:group-details", kwargs={"pk": str(self.admin_group.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(self.admin_group.id), - "members_count": self.admin_group.memberships.count(), - "name": self.admin_group.name, - "public": self.admin_group.public, - "level": Role.Admin.value - }) - - def test_update_group_no_admin(self): - """ - User must be a group administrator to edit its properties - """ - self.client.force_login(self.non_admin) - with self.assertNumQueries(5): - response = self.client.patch(reverse("api:group-details", kwargs={"pk": str(self.group.id)}), {"name": "A"}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), { - "detail": "Only members with an admin privilege may update or delete this group." - }) - - def test_update_group(self): - """ - Group name and public attributes may be updated by an admin member - """ - payload = { - "name": "Renamed group", - "public": True, - "id": uuid.uuid4(), - "members_count": 42, - "level": 42 - } - self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.put( - reverse("api:group-details", kwargs={"pk": str(self.group.id)}), - payload - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(self.group.id), - "members_count": 3, - "name": "Renamed group", - "public": True, - "level": Role.Admin.value - }) - - def test_update_group_superuser(self): - """ - A superuser is allowed to retrieve any group. Its level is always 100 - """ - self.client.force_login(self.superuser) - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:group-details", kwargs={"pk": str(self.admin_group.id)}), - {"public": False, "name": "I got you"} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(self.admin_group.id), - "members_count": self.admin_group.memberships.count(), - "name": "I got you", - "public": False, - "level": Role.Admin.value - }) - - def test_delete_group_no_admin(self): - self.client.force_login(self.non_admin) - with self.assertNumQueries(5): - response = self.client.delete(reverse("api:group-details", kwargs={"pk": str(self.group.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), { - "detail": "Only members with an admin privilege may update or delete this group." - }) - - def test_delete_group(self): - """ - A group admin is allowed to delete the group - """ - group = Group.objects.create(name="Another group") - user = User.objects.create(email="user42@test.test", verified_email=True) - group.add_member(user=user, level=Role.Admin.value) - self.client.force_login(user) - with self.assertNumQueries(7): - response = self.client.delete(reverse("api:group-details", kwargs={"pk": str(group.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - with self.assertRaises(Group.DoesNotExist): - group.refresh_from_db() - self.assertTrue(User.objects.filter(id=user.id).exists()) - - def test_delete_group_superuser(self): - """ - A superuser is allowed to delete an existing group - """ - self.client.force_login(self.superuser) - with self.assertNumQueries(7): - response = self.client.delete(reverse("api:group-details", kwargs={"pk": str(self.admin_group.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - with self.assertRaises(Group.DoesNotExist): - self.admin_group.refresh_from_db() - - def test_list_user_memberships_requires_login(self): - """ - User must be logged in to list its memberships - """ - with self.assertNumQueries(0): - response = self.client.delete(reverse("api:group-details", kwargs={"pk": str(self.group.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - - def test_list_user_memberships(self): - """ - List groups for which the user is a member - """ - new_group = Group.objects.create(name="Another group", public=False) - new_group_member = new_group.add_member(user=self.user, level=10) - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:user-memberships")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "count": 2, - "number": 1, - "next": None, - "previous": None, - "results": [ - { - "group": - { - "id": str(new_group.id), - "members_count": 1, - "name": "Another group", - "public": False - }, - "id": str(new_group_member.id), - "level": new_group_member.level, - }, { - "group": - { - "id": str(self.group.id), - "members_count": self.group.memberships.count(), - "name": self.group.name, - "public": self.group.public - }, - "id": str(self.membership.id), - "level": self.membership.level, - } - ] - }) - - def test_list_user_memberships_superuser(self): - """ - A superuser is able to list all groups. Memberships have no ID nor level - """ - new_group = Group.objects.create(name="Another group", public=False) - new_group.add_member(user=self.user, level=10) - self.client.force_login(self.superuser) - with self.assertNumQueries(3): - response = self.client.get(reverse("api:user-memberships")) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "count": 3, - "number": 1, - "next": None, - "previous": None, - "results": [ - { - "group": { - "id": str(self.admin_group.id), - "members_count": 1, - "name": "Admin group", - "public": True - }, - "id": None, - "level": None, - }, { - "group": - { - "id": str(new_group.id), - "members_count": 1, - "name": "Another group", - "public": False - }, - "id": None, - "level": None, - }, { - "group": - { - "id": str(self.group.id), - "members_count": self.group.memberships.count(), - "name": self.group.name, - "public": self.group.public - }, - "id": None, - "level": None, - } - ] - }) - - def test_add_member_non_existing_group(self): - """ - Cannot add a member to a non existing group - """ - self.client.force_login(self.user) - with self.assertNumQueries(4): - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "user_email": self.non_admin.email, - "content_type": "group", - "content_id": str(uuid.uuid4()) - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - {"content_id": ["Group with this ID could not be found."]} - ) - - def test_add_member_non_member(self): - """ - Raise a 403 in case the user is not a member of the target content - """ - self.client.force_login(self.user) - worker = Worker.objects.get(slug="reco") - with self.assertNumQueries(9): - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "user_email": self.non_admin.email, - "content_type": "worker", - "content_id": str(worker.id) - }) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), {"detail": "You are not a member of the target content object."}) - - def test_add_member_requires_login(self): - with self.assertNumQueries(0): - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "user_email": self.non_admin.email, - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - - def test_add_member_requires_verified(self): - self.client.force_login(self.unverified) - with self.assertNumQueries(2): - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "user_email": self.non_admin.email, - "content_type": "group", - "content_id": str(uuid.uuid4()) - }) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - - def test_add_member_requires_group_admin(self): - """ - Only group admins have the ability to add a new member - Note that the user is already a member but this check is performed after - """ - self.client.force_login(self.non_admin) - with self.assertNumQueries(5): - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "user_email": self.non_admin.email, - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "content_id": ["Only members with an admin privilege are allowed to add other members."] - }) - - def test_add_member_already_member(self): - """ - A member cannot be added twice - """ - self.client.force_login(self.user) - with self.assertNumQueries(7): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - "user_email": self.non_admin.email, - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "__all__": ["This membership already exists."] - }) - - def test_add_member_non_existing_user(self): - """ - Cannot add a non existing user - """ - self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - "user_email": "undefined@nowhe.re", - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "user_email": ["No user matching this email could be found."] - }) - - def test_add_member_required_fields(self): - """ - Content type, id and level are required fields - """ - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.post(reverse("api:membership-create"), { - "access": 42, - "id": 1337, - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "content_id": ["This field is required."], - "content_type": ["This field is required."], - "level": ["This field is required."] - }) - - def test_add_member_wrong_content_type(self): - self.client.force_login(self.user) - with self.assertNumQueries(2): - response = self.client.post(reverse("api:membership-create"), { - "content_type": "gloubiboulga", - "content_id": uuid.uuid4(), - "level": 42 - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "content_type": ["'gloubiboulga' is not a valid content type."] - }) - - def test_add_member_right_target_xor(self): - """ - Exactly one of group_id and user_email field must be defined - """ - wrong_targets = ( - {}, - {"user_email": "aaa@aa.com", "group_id": str(uuid.uuid4())} - ) - self.client.force_login(self.user) - for wrong_target in wrong_targets: - response = self.client.post(reverse("api:membership-create"), { - "level": 42, - "content_type": "corpus", - "content_id": str(self.corpus.id), - **wrong_target - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "detail": ["Exactly one of those fields must be defined: user_email, group_id"] - }) - - def test_add_member_level_validation(self): - """ - Level should be an integer between 0 and 100 (included) - """ - checks = ( - (101, {"level": ["Ensure this value is less than or equal to 100."]}), - (-1, {"level": ["Ensure this value is greater than or equal to 1."]}) - ) - self.client.force_login(self.user) - for level, error in checks: - response = self.client.post(reverse("api:membership-create"), { - "level": level, - "user_email": self.admin.email, - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), error) - - def test_add_member_by_email(self): - """ - Adds a new member referenced by its email - """ - user = User.objects.create_user("test@test.de", "Pa$$w0rd") - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - "user_email": user.email, - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - membership = user.rights.get(group_target=self.group) - self.assertDictEqual(response.json(), { - "id": str(membership.id), - "content_id": str(self.group.id), - "content_type": "group", - "level": 10, - "user_email": user.email, - "group_id": None, - }) - - def test_add_member_by_email_uppercase_letters(self): - """ - Adds a new member referenced by its email - Asserts the endpoint is case insensitive for the email - """ - user = User.objects.create_user("test@test.de", "Pa$$w0rd") - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - # Introducing uppercase letters in the user's email - "user_email": "tEsT@TeSt.DE", - "content_type": "group", - "content_id": str(self.group.id) - }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - membership = user.rights.get(group_target=self.group) - self.assertDictEqual(response.json(), { - "id": str(membership.id), - "content_id": str(self.group.id), - "content_type": "group", - "level": 10, - "user_email": user.email, - "group_id": None, - }) - - def test_add_member_superuser(self): - """ - A superuser is able to add a member to a groups he has no right on - """ - user = User.objects.create_user("test@test.de", "Pa$$w0rd") - self.client.force_login(self.superuser) - with self.assertNumQueries(7): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - "user_email": user.email, - "content_type": "group", - "content_id": str(self.admin_group.id) - }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_add_member_via_repo_right(self): - """ - A worker member can inherit the right to add a member from its repository - """ - user = User.objects.create_user("test@test.de", "Pa$$w0rd") - - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - repo.memberships.create(user=self.user, level=Role.Admin.value) - - self.client.force_login(self.user) - with self.assertNumQueries(11): - response = self.client.post(reverse("api:membership-create"), { - "level": 10, - "user_email": user.email, - "content_type": "worker", - "content_id": str(worker.id) - }) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_retrieve_membership_not_found(self): - """ - Only memberships on groups the user has a read access can be retrieved - """ - hidden_group = Group.objects.create(name="Hidden group", public=False) - hidden_member = hidden_group.add_member(user=self.admin, level=Role.Guest.value) - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:membership-details", kwargs={"pk": str(hidden_member.id)})) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve_membership_details(self): - """ - Any group member can retrieve a specific membership - """ - self.client.force_login(self.non_admin) - with self.assertNumQueries(6): - response = self.client.get(reverse("api:membership-details", kwargs={"pk": str(self.membership.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(self.membership.id), - "level": Role.Admin.value - }) - - def test_retrieve_membership_details_superuser(self): - """ - Any group member can retrieve a specific membership - """ - self.client.force_login(self.superuser) - with self.assertNumQueries(5): - response = self.client.get(reverse("api:membership-details", kwargs={"pk": str(self.membership.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_edit_membership_requires_verified(self): - self.client.force_login(self.unverified) - with self.assertNumQueries(2): - response = self.client.patch(reverse("api:membership-details", kwargs={"pk": str(uuid.uuid4())}), {}) - self.assertDictEqual(response.json(), {"detail": "You do not have permission to perform this action."}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_edit_membership_requires_admin(self): - """ - The request user has to be an admin of the target member group to edit its membership - """ - self.client.force_login(self.non_admin) - with self.assertNumQueries(6): - response = self.client.patch(reverse("api:membership-details", kwargs={"pk": str(self.membership.id)}), {}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual(response.json(), { - "detail": "Only admins of the target membership group can perform this action." - }) - - def test_edit_membership_last_admin(self): - """ - At least one admin should be present in the group on a member edition - """ - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.patch( - reverse("api:membership-details", kwargs={"pk": str(self.membership.id)}), - {"level": 1} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - {"level": ["Removing all memberships with an admin privilege is not possible."]} - ) - - def test_edit_membership_last_admin_workers(self): - """ - Workers may specially have no member as they depends on a repository - """ - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - membership = worker.memberships.create(user=self.user, level=Role.Admin.value) - self.client.force_login(self.user) - with self.assertNumQueries(10): - response = self.client.patch( - reverse("api:membership-details", kwargs={"pk": str(membership.id)}), - {"level": Role.Guest.value} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - # Only one guest right exists on the worker - self.assertEqual([m.level for m in worker.memberships.all()], [Role.Guest.value]) - - def test_edit_membership_wrong_fields(self): - self.non_admin.rights.update(level=Role.Admin.value) - self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:membership-details", kwargs={"pk": str(self.membership.id)}), - {"level": 101} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), { - "level": ["Ensure this value is less than or equal to 100."] - }) - - def test_edit_membership(self): - """ - Only level field may be edited - """ - self.client.force_login(self.user) - non_admin_member = self.non_admin.rights.get(group_target=self.group) - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:membership-details", kwargs={"pk": str(non_admin_member.id)}), - {"level": 11, "id": uuid.uuid4(), "content_type": "A", "content_id": uuid.uuid4()} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.json(), { - "id": str(non_admin_member.id), - "level": 11 - }) - - def test_edit_privilege_escalation(self): - """ - A user cannot update its own access level if he has no admin access - """ - second_admin = User.objects.create(email="another_user@test.fr", verified_email=True) - self.group.memberships.create(user=second_admin, level=Role.Admin.value) - cases = [ - (self.non_admin, Role.Guest.value, status.HTTP_403_FORBIDDEN), - (self.non_admin, Role.Admin.value, status.HTTP_403_FORBIDDEN), - (second_admin, Role.Guest.value, status.HTTP_200_OK), - ] - for user, level, response_status in cases: - self.client.force_login(user) - membership = self.group.memberships.get(user=user) - response = self.client.put( - reverse("api:membership-details", kwargs={"pk": str(membership.id)}), - {"level": level} - ) - self.assertEqual(response.status_code, response_status) - - def test_edit_membership_superadmin(self): - """ - Superadmins are allowed to update any membership level - """ - new_member = self.admin_group.add_member(user=self.user, level=10) - self.client.force_login(self.superuser) - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:membership-details", kwargs={"pk": str(new_member.id)}), {"level": 42} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - new_member.refresh_from_db() - self.assertEqual(new_member.level, 42) - - def test_edit_member_via_repo_right(self): - """ - A worker member can inherit the right to add a member from its repository to edit a worker member - """ - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - repo.memberships.create(user=self.user, level=Role.Admin.value) - - new_user = User.objects.create_user("test@test.de", "Pa$$w0rd") - new_member = worker.memberships.create(user=new_user, level=Role.Guest.value) - - self.client.force_login(self.user) - with self.assertNumQueries(9): - response = self.client.patch(reverse("api:membership-details", kwargs={"pk": str(new_member.id)}), { - "level": Role.Admin.value, - }) - self.assertEqual(response.status_code, status.HTTP_200_OK) - new_member.refresh_from_db() - self.assertEqual(new_member.level, Role.Admin.value) - - def test_delete_membership_last_admin(self): - """ - At least one admin should be present for the corresponding object on a member deletion - """ - # The user is the only admin for this corpus - corpus_membership = self.corpus.memberships.get(user=self.user) - self.client.force_login(self.user) - with self.assertNumQueries(8): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(corpus_membership.id)})) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual( - response.json(), - {"detail": ["Removing all memberships with an admin privilege is not possible."]} - ) - - def test_delete_membership_remaining_admins(self): - """ - It is possible to downgrade an admin privilege if there are other admins for the corresponding object - """ - admin_group = Group.objects.create(name="Another group") - # We add a group as an admin of the user corpus - group_membership = admin_group.rights.create(content_object=self.corpus, level=Role.Admin.value) - self.client.force_login(self.user) - with self.assertNumQueries(9): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(group_membership.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(admin_group.rights.count(), 0) - - def test_delete_membership_last_admin_workers(self): - """ - Workers may have no member as they depends on a repository - """ - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - membership = worker.memberships.create(user=self.user, level=Role.Admin.value) - self.client.force_login(self.user) - with self.assertNumQueries(10): - response = self.client.delete( - reverse("api:membership-details", kwargs={"pk": str(membership.id)}), - {"level": Role.Guest.value} - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - # Only one guest right exists on the worker - self.assertEqual(worker.memberships.count(), 0) - - def test_delete_membership_non_admin(self): - """ - Non admin members are not allowed to remove another member - """ - self.client.force_login(self.non_admin) - with self.assertNumQueries(6): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(self.membership.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertDictEqual( - response.json(), - {"detail": "Only admins of the target membership group can perform this action."} - ) - - def test_delete_membership_superuser(self): - """ - Superadmins are allowed to delete any existing membership - """ - new_member = self.admin_group.add_member(user=self.user, level=10) - self.client.force_login(self.superuser) - with self.assertNumQueries(6): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(new_member.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - def test_delete_own_membership(self): - """ - Any member is able to remove its own membership - """ - non_admin_membership = self.group.memberships.get(user=self.non_admin) - self.client.force_login(self.non_admin) - with self.assertNumQueries(7): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(non_admin_membership.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - with self.assertRaises(Right.DoesNotExist): - non_admin_membership.refresh_from_db() - - def test_delete_member_via_repo_right(self): - """ - A worker member can inherit the right to add a member from its repository to remove a worker member - """ - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - worker = repo.workers.get(slug="reco") - repo.memberships.create(user=self.user, level=Role.Admin.value) - - new_user = User.objects.create_user("test@test.de", "Pa$$w0rd") - new_member = worker.memberships.create(user=new_user, level=Role.Guest.value) - - self.client.force_login(self.user) - with self.assertNumQueries(9): - response = self.client.delete(reverse("api:membership-details", kwargs={"pk": str(new_member.id)})) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - with self.assertRaises(Right.DoesNotExist): - new_member.refresh_from_db() - - def test_right_unique_user(self): - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - repo.memberships.create(user=self.user, level=Role.Admin.value) - with self.assertRaises(IntegrityError): - repo.memberships.create(user=self.user, level=Role.Admin.value) - - def test_right_unique_group(self): - repo = Repository.objects.get(url="http://my_repo.fake/workers/worker") - repo.memberships.create(group=self.group, level=Role.Admin.value) - with self.assertRaises(IntegrityError): - repo.memberships.create(group=self.group, level=Role.Admin.value) diff --git a/arkindex/users/tests/test_manager_acl.py b/arkindex/users/tests/test_manager_acl.py deleted file mode 100644 index fe7665abb5..0000000000 --- a/arkindex/users/tests/test_manager_acl.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.contrib.auth.models import AnonymousUser -from django.test import TestCase - -from arkindex.documents.models import Corpus -from arkindex.users.models import Role, User - - -class TestACL(TestCase): - """ - Test Corpus manager ACL helpers - """ - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.anon = AnonymousUser() - cls.user = User.objects.create_user("user@address.com", "P4$5w0Rd") - cls.admin = User.objects.create_superuser("admin@address.com", "P4$5w0Rd") - - cls.corpus_public = Corpus.objects.create(name="A Public", public=True) - cls.corpus_private = Corpus.objects.create(name="B Private") - cls.corpus_private.memberships.create(user=cls.user, level=Role.Guest.value) - cls.corpus_hidden = Corpus.objects.create(name="C Hidden") - - def test_anon(self): - # An anonymous user has only access to public - self.assertCountEqual( - Corpus.objects.readable(self.anon).values_list("id", flat=True), - [self.corpus_public.id] - ) - - def test_user(self): - # An anonymous user has access to public & private - self.assertFalse(self.user.is_admin) - self.assertCountEqual( - Corpus.objects.readable(self.user).values_list("id", flat=True), - [ - self.corpus_private.id, - *Corpus.objects.filter(public=True).values_list("id", flat=True) - ] - ) - - def test_admin(self): - # An admin has access with admin privileges to all corpora - self.assertTrue(self.admin.is_admin) - self.assertCountEqual( - Corpus.objects.admin(self.admin).values_list("id"), - Corpus.objects.all().values_list("id") - ) diff --git a/arkindex/users/tests/test_registration.py b/arkindex/users/tests/test_registration.py index f0f9e6206e..7376532c48 100644 --- a/arkindex/users/tests/test_registration.py +++ b/arkindex/users/tests/test_registration.py @@ -1,4 +1,5 @@ import urllib.parse +from unittest.mock import call, patch from django.contrib import auth from django.contrib.auth.tokens import default_token_generator @@ -117,7 +118,8 @@ class TestRegistration(FixtureAPITestCase): self.assertTrue(auth.get_user(self.client).is_authenticated) self.assertEqual(auth.get_user(self.client).email, "email@address.com") - def test_email_verification(self): + @patch("arkindex.users.managers.BaseACLManager.filter_rights", return_value=Corpus.objects.none()) + def test_email_verification(self, filter_rights_mock): newuser = User.objects.create_user("newuser@example.com", password="hunter2") self.assertFalse(newuser.verified_email) self.assertTrue(newuser.has_usable_password()) @@ -135,18 +137,19 @@ class TestRegistration(FixtureAPITestCase): newuser.refresh_from_db() self.assertTrue(newuser.verified_email) + self.assertEqual(filter_rights_mock.call_count, 1) + self.assertEqual(filter_rights_mock.call_args, call(newuser, Corpus, Role.Admin.value)) + # Assert a trial corpus is created for the new user - new_corpus = Corpus.objects.exclude(id__in=corpora) - self.assertEqual(new_corpus.count(), 1) - self.assertEqual(new_corpus.get().name, "My Project") - self.assertEqual(new_corpus.get().description, "Project for newuser@example.com") - self.assertCountEqual( - list(Corpus.objects.admin(newuser)), - list(new_corpus) - ) + new_corpus = Corpus.objects.exclude(id__in=corpora).get() + self.assertEqual(new_corpus.name, "My Project") + self.assertEqual(new_corpus.description, "Project for newuser@example.com") + membership = new_corpus.memberships.get() + self.assertEqual(membership.user, newuser) + self.assertEqual(membership.level, Role.Admin.value) # Assert defaults types are set on the new corpus self.assertCountEqual( - list(new_corpus.get().types.values( + list(new_corpus.types.values( "slug", "display_name", "folder", @@ -172,7 +175,7 @@ class TestRegistration(FixtureAPITestCase): newuser = User.objects.create_user("newuser@example.com", password="hunter2") self.assertFalse(newuser.verified_email) - with (self.settings(SIGNUP_DEFAULT_GROUP=str(test_group.id)), self.assertNumQueries(19)): + with (self.settings(SIGNUP_DEFAULT_GROUP=str(test_group.id)), self.assertNumQueries(15)): response = self.client.get("{}?{}".format( reverse("api:user-token"), urllib.parse.urlencode({ @@ -192,7 +195,7 @@ class TestRegistration(FixtureAPITestCase): with ( self.settings(SIGNUP_DEFAULT_GROUP="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - self.assertNumQueries(15), + self.assertNumQueries(10), self.assertRaises(Group.DoesNotExist) ): self.client.get("{}?{}".format( diff --git a/arkindex/users/utils.py b/arkindex/users/utils.py index 201708f558..9fcf143b03 100644 --- a/arkindex/users/utils.py +++ b/arkindex/users/utils.py @@ -1,119 +1,8 @@ -from django.db.models import IntegerField, Q, Value, functions -from django.db.models.query_utils import DeferredAttribute -from enumfields import Enum +from django.conf import settings +from django.utils.module_loading import cached_import -from arkindex.documents.models import Corpus -from arkindex.ponos.models import Farm -from arkindex.process.models import Repository, Worker -from arkindex.training.models import Model -from arkindex.users.models import Group, Role +_filter_module = getattr(settings, "RIGHTS_FILTER_MODULE", None) or "arkindex.users.allow_all" -PUBLIC_LEVEL = Role.Guest.value - - -class RightContent(Enum): - corpus = Corpus - repository = Repository - group = Group - worker = Worker - model = Model - farm = Farm - - -def has_public_field(model): - return isinstance( - getattr(model, "public", None), - DeferredAttribute - ) - - -def get_public_instances(model): - return model.objects \ - .filter(public=True) \ - .annotate(max_level=Value(PUBLIC_LEVEL, IntegerField())) - - -def check_level_param(level): - assert isinstance(level, int), "An integer level is required to compare access rights." - assert level >= 1, "Level integer should be greater than or equal to 1." - assert level <= 100, "level integer should be lower than or equal to 100" - - -def filter_rights(user, model, level): - """ - Return a generic queryset of objects with access rights for this user. - Level filtering parameter should be an integer between 1 and 100. - """ - check_level_param(level) - - public = level <= PUBLIC_LEVEL and has_public_field(model) - - # Handle special authentications - if user.is_anonymous: - # Anonymous users have Guest access on public instances only - if not public: - return model.objects.none() - return get_public_instances(model) - elif user.is_admin: - # Superusers have an Admin access to all corpora - return model.objects.all() \ - .annotate(max_level=Value(Role.Admin.value, IntegerField())) - - # Filter users rights and annotate the resulting level for those rights - queryset = model.objects \ - .filter( - # Filter instances with rights concerning this user - Q(memberships__user=user) - | Q(memberships__group__memberships__user=user) - ) \ - .annotate( - # Keep only the lowest level for each right via group - max_level=functions.Least( - "memberships__level", - # In case of direct right, the group level will be skipped (Null value) - "memberships__group__memberships__level" - ) - ) \ - .filter(max_level__gte=level) - - # Use a join to add public instances as this is the more elegant solution - if public: - queryset = queryset.union(get_public_instances(model)) - - return queryset - - -def get_max_level(user, instance): - """ - Returns the maximum access level on a given instance - """ - default_level = None - if getattr(instance, "public", False): - default_level = PUBLIC_LEVEL - - # Handle special authentications - if user.is_anonymous: - return default_level - elif user.is_admin: - # Superusers have an Admin access to all corpora - return Role.Admin.value - - rights = instance.memberships \ - .filter( - # Filter instances with rights concerning this user - Q(user=user) - | Q(group__memberships__user=user) - ) \ - .annotate( - # Keep only the lowest level for each right via group - max_level=functions.Least( - "level", - # In case of direct right, the group level will be skipped (Null value) - "group__memberships__level" - ) - ) - - # Filter out the right with the maximum level - max_right = max(rights, key=lambda r: r.max_level, default=None) - - return max_right and max_right.max_level or default_level +filter_rights = cached_import(_filter_module, "filter_rights") +get_max_level = cached_import(_filter_module, "get_max_level") +has_access = cached_import(_filter_module, "has_access") diff --git a/requirements.txt b/requirements.txt index fea699888b..88d51a64c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,9 @@ django-pgtrigger==4.7.0 django-rq==2.8.1 djangorestframework==3.12.4 djangorestframework-simplejwt==5.2.2 +docker==7.0.0 drf-spectacular==0.18.2 +python-magic==0.4.27 python-memcached==1.59 pytz==2023.3 PyYAML==6.0 -- GitLab