diff --git a/arkindex/process/tests/test_workerruns.py b/arkindex/process/tests/test_workerruns.py deleted file mode 100644 index 40ad68fcdeb66fe6477998646c1e3d51ad717598..0000000000000000000000000000000000000000 --- a/arkindex/process/tests/test_workerruns.py +++ /dev/null @@ -1,2969 +0,0 @@ -import uuid -from datetime import datetime, timezone -from unittest.mock import call, patch - -from django.db import transaction -from django.urls import reverse -from rest_framework import status -from rest_framework.exceptions import ValidationError - -from arkindex.ponos.models import Agent, Farm, State -from arkindex.process.models import ( - FeatureUsage, - ProcessMode, - Worker, - WorkerRun, - WorkerVersion, - WorkerVersionState, -) -from arkindex.project.tests import FixtureAPITestCase -from arkindex.training.models import Model, ModelVersion, ModelVersionState -from arkindex.users.models import Role - -ENV = { - "ARKINDEX_PROCESS_ID": "12345" -} - - -class TestWorkerRuns(FixtureAPITestCase): - """ - Test worker runs endpoints and methods - """ - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) - cls.farm = Farm.objects.first() - cls.process_1 = cls.corpus.processes.create( - creator=cls.user, - mode=ProcessMode.Workers, - farm=cls.farm, - ) - cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") - cls.worker_1 = cls.version_1.worker - cls.worker_custom = Worker.objects.get(slug="custom") - cls.version_2 = WorkerVersion.objects.get(worker__slug="dla") - cls.version_custom = cls.worker_custom.versions.get() - cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) - cls.run_custom = cls.local_process.worker_runs.get(version=cls.version_custom) - cls.configuration_1 = cls.worker_1.configurations.create(name="My config", configuration={"key": "value"}) - worker_version = WorkerVersion.objects.exclude(worker=cls.version_1.worker).first() - cls.configuration_2 = worker_version.worker.configurations.create(name="Config") - cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) - # Add an execution access right on the worker - cls.worker_1.memberships.create(user=cls.user, level=Role.Contributor.value) - - # Model and Model version setup - cls.model_1 = Model.objects.create(name="My model") - cls.model_1.memberships.create(user=cls.user, level=Role.Contributor.value) - cls.model_version_1 = ModelVersion.objects.create( - model=cls.model_1, - state=ModelVersionState.Available, - size=8, - hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ) - - cls.model_2 = Model.objects.create(name="Their model") - cls.model_2.memberships.create(user=cls.user, level=Role.Guest.value) - cls.model_version_2 = cls.model_2.versions.create( - state=ModelVersionState.Available, - tag="blah", - size=8, - hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ) - - cls.model_3 = Model.objects.create(name="Our model", public=True) - cls.model_version_3 = cls.model_3.versions.create( - state=ModelVersionState.Available, - tag="blah", - size=8, - hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ) - - cls.agent = Agent.objects.create( - farm=cls.farm, - hostname="claude", - cpu_cores=42, - cpu_frequency=1e15, - ram_total=99e9, - last_ping=datetime.now(timezone.utc), - ) - # Add custom attributes to make the agent usable as an authenticated user - cls.agent.is_agent = True - cls.agent.is_anonymous = False - - def test_list_requires_login(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_no_execution_right(self): - """ - Worker runs attached to a process can be listed even if the user has no execution rights to workers - This is due to the fact a user can see a process running on a corpus they have access - """ - self.worker_1.memberships.all().delete() - self.client.force_login(self.user) - - with self.assertNumQueries(6): - 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) - - self.assertEqual(response.json()["count"], 1) - - def test_list(self): - self.client.force_login(self.user) - - with self.assertNumQueries(6): - 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) - - data = response.json() - self.assertEqual(data["results"], [{ - "id": str(self.run_1.id), - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }]) - - def test_list_filter_process(self): - run_2 = self.process_2.worker_runs.create( - version=self.version_1, - parents=[], - ) - self.client.force_login(self.user) - - with self.assertNumQueries(6): - 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) - - data = response.json() - self.assertEqual(data["count"], 1) - self.assertEqual(data["results"][0]["id"], str(run_2.id)) - - def test_create_requires_login(self): - with self.assertNumQueries(0): - 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": []}, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) - - @patch("arkindex.users.utils.get_max_level", return_value=Role.Guest.value) - def test_create_no_execution_right(self, max_level_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(5): - 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": []}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), {"worker_version_id": ["You do not have an execution access to this worker."]}) - - self.assertListEqual(max_level_mock.call_args_list, [ - call(self.user, self.worker_1) - ]) - - def test_create_invalid_version(self): - self.client.force_login(self.user) - version_id = uuid.uuid4() - - 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}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "worker_version_id": [f'Invalid pk "{version_id}" - object does not exist.'], - }) - - def test_create_unique(self): - self.client.force_login(self.user) - self.version_1.model_usage = FeatureUsage.Required - self.version_1.save() - cases = [ - (None, None), - (None, self.configuration_1), - (self.model_version_1, None), - (self.model_version_1, self.configuration_1), - ] - for model_version, configuration in cases: - with self.subTest(model_version=model_version, configuration=configuration): - self.run_1.model_version = model_version - self.run_1.configuration = configuration - self.run_1.save() - - # 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.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)}), - data={ - "worker_version_id": str(self.version_1.id), - "model_version_id": str(model_version.id) if model_version else None, - "configuration_id": str(configuration.id) if configuration else None, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], - }) - - def test_create_unavailable_version(self): - self.version_1.state = WorkerVersionState.Error - self.version_1.save() - self.client.force_login(self.user) - - with self.assertNumQueries(5): - 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": []}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), {"worker_version_id": ["This WorkerVersion is not in an Available state."]}) - - def test_create_archived_worker(self): - self.worker_1.archived = datetime.now(timezone.utc) - self.worker_1.save() - self.client.force_login(self.user) - - with self.assertNumQueries(5): - 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": []}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), {"worker_version_id": ["This WorkerVersion is part of an archived worker."]}) - - def test_create_archived_model(self): - self.model_1.archived = datetime.now(timezone.utc) - self.model_1.save() - self.version_1.model_usage = FeatureUsage.Supported - self.version_1.save() - self.client.force_login(self.user) - - 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), "model_version_id": str(self.model_version_1.id)}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), {"model_version_id": ["This ModelVersion is part of an archived model."]}) - - def test_create_invalid_process_id(self): - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": uuid.uuid4()}), - data={"worker_version_id": str(self.version_1.id), "parents": []}, - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - self.assertEqual(response.json(), {"detail": "No Process matches the given query."}) - - def test_create_invalid_process_mode(self): - self.client.force_login(self.user) - - for mode in set(ProcessMode) - {ProcessMode.Workers, ProcessMode.Dataset, ProcessMode.Local}: - with self.subTest(mode=mode): - self.process_2.mode = mode - self.process_2.save() - - with self.assertNumQueries(5): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), - {"worker_version_id": str(self.version_1.id)}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], - }) - - def test_create_non_corpus_process_mode(self): - process = self.user.processes.get(mode=ProcessMode.Local) - self.client.force_login(self.user) - with self.assertNumQueries(3): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(process.id)}), - {"worker_version_id": str(self.version_1.id), "parents": []}, - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_create_process_already_started(self): - process = self.corpus.processes.create( - creator=self.user, - mode=ProcessMode.Workers, - farm=self.farm, - ) - process.run() - - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(process.id)}), - data={"worker_version_id": str(self.version_1.id), "parents": []}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], - }) - - def test_create(self): - self.client.force_login(self.user) - - for mode in (ProcessMode.Workers, ProcessMode.Dataset): - self.process_2.worker_runs.filter(version=self.version_1).delete() - - with self.subTest(mode=mode): - self.process_2.mode = mode - self.process_2.save() - - 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), "parents": []}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - data = response.json() - pk = data.pop("id") - self.assertNotEqual(pk, self.run_1.id) - self.assertDictEqual(data, { - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.process_2.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": mode.value, - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - run = WorkerRun.objects.get(pk=pk) - # Check generated summary - self.assertEqual(run.summary, "Worker Recognizer @ version 1") - - def test_create_empty(self): - """ - The worker_version_id is required to create a worker run - """ - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), { - "worker_version_id": ["This field is required."], - }) - - def test_create_configuration(self): - self.client.force_login(self.user) - - 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_1.id) - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - data = response.json() - pk = data.pop("id") - self.assertNotEqual(pk, self.run_1.id) - self.assertDictEqual(data, { - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": { - "id": str(self.configuration_1.id), - "archived": False, - "configuration": {"key": "value"}, - "name": "My config" - }, - "process": { - "id": str(self.process_2.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1 using configuration 'My config'", - }) - run = WorkerRun.objects.get(pk=pk) - # Check generated summary - self.assertEqual(run.summary, "Worker Recognizer @ version 1 using configuration 'My config'") - - def test_create_invalid_configuration(self): - self.client.force_login(self.user) - - 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": [], "configuration_id": str(self.configuration_2.id)}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) - - def test_summary(self): - self.client.force_login(self.user) - test_version = self.worker_1.versions.create( - version=2, - state=WorkerVersionState.Available, - docker_image_iid="registry.gitlab.com/dead-sea-scrolls:004", - model_usage=FeatureUsage.Supported - ) - - cases = [ - ("eva-01", "revision_url", self.model_version_1, self.configuration_1, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]}) with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), - (None, "revision_url", self.model_version_1, self.configuration_1, f"Worker Recognizer @ {str(test_version.id)[:6]} with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), - ("eva-01", "revision_url", None, self.configuration_1, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]}) using configuration 'My config'"), - (None, "revision_url", self.model_version_1, None, f"Worker Recognizer @ {str(test_version.id)[:6]} with model My model @ {str(self.model_version_1.id)[:6]}"), - ("eva-01", "revision_url", None, None, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]})"), - (None, "revision_url", None, None, f"Worker Recognizer @ {str(test_version.id)[:6]}"), - ("eva-01", None, self.model_version_1, self.configuration_1, f"Worker Recognizer @ eva-01 (version 2) with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), - (None, None, self.model_version_1, self.configuration_1, f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), - ("eva-01", None, None, self.configuration_1, "Worker Recognizer @ eva-01 (version 2) using configuration 'My config'"), - (None, None, self.model_version_1, None, f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]}"), - ("eva-01", None, None, None, "Worker Recognizer @ eva-01 (version 2)"), - (None, None, None, None, "Worker Recognizer @ version 2"), - ] - - for tag, revision_url, model_version, config, expected_summary in cases: - with self.subTest(tag=tag, model_version=model_version, config=config), transaction.atomic(): - # Clear the process of worker runs - self.process_2.worker_runs.all().delete() - - num_queries = 6 - - test_version.version = None - test_version.tag = tag - test_version.revision_url = revision_url - if not revision_url: - test_version.version = 2 - test_version.save() - - payload = { - "worker_version_id": str(test_version.id), - "parents": [], - } - if model_version: - payload["model_version_id"] = model_version.id - # If there is a model version, it adds a query - num_queries += 1 - if config: - payload["configuration_id"] = config.id - # If there is a configuration, it adds a query - num_queries += 1 - - with self.assertNumQueries(num_queries): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), - data=payload, - ) - if response.status_code == 400: - self.assertDictEqual(response.json(), {}) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - created_run = WorkerRun.objects.get(pk=response.json()["id"]) - self.assertEqual(created_run.summary, expected_summary) - - def test_retrieve_requires_login(self): - with self.assertNumQueries(0): - response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)})) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_retrieve_no_worker_execution_right(self): - """ - A user can retrieve any worker run if they have an admin access to the process project - """ - self.worker_1.memberships.update(level=Role.Guest.value) - self.client.force_login(self.user) - - with self.assertNumQueries(4): - 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) - - self.assertDictEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - def test_retrieve_invalid_id(self): - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.get( - reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}) - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve(self): - self.client.force_login(self.user) - - with self.assertNumQueries(4): - 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) - - self.assertDictEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - def test_retrieve_custom(self): - self.client.force_login(self.user) - # Update process attributes to ensure they do not cause extra requests - page = self.corpus.elements.get(name="Volume 1, page 1r") - self.local_process.element = page - self.local_process.element_type = page.type - self.local_process.folder_type = page.type - self.local_process.prefix = "a" - self.local_process.save() - with self.assertNumQueries(5): - response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_custom.id)})) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual(response.json(), { - "id": str(self.run_custom.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.local_process.id), - "activity_state": "disabled", - "corpus": None, - "chunks": 1, - "mode": "local", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": { - "id": str(page.id), - "type": "page", - "corpus": { - "id": str(self.corpus.id), - "name": "Unit Tests", - "public": True - }, - "name": "Volume 1, page 1r", - "rotation_angle": 0, - "mirrored": False, - "thumbnail_url": None, - "zone": { - "id": str(page.id), - "image": { - "id": str(page.image.id), - "height": 1000, - "path": "img1", - "s3_url": None, - "server": { - "display_name": "Test Server", - "max_height": None, - "max_width": None, - "url": "http://server" - }, - "status": "unchecked", - "url": "http://server/img1", - "width": 1000 - }, - "polygon": [[int(x), int(y)] for x, y in page.polygon], - "url": "http://server/img1/0,0,1000,1000/full/0/default.jpg", - }, - }, - "element_type": "page", - "folder_type": "page", - "prefix": "a", - }, - "worker_version": { - "id": str(self.version_custom.id), - "configuration": {"custom": "value"}, - "docker_image_iid": None, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "created", - "worker": { - "id": str(self.worker_custom.id), - "name": "Custom worker", - "slug": "custom", - "type": "custom", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "summary": "Worker Custom worker @ version 1", - "use_gpu": False, - }) - - def test_retrieve_local(self): - """ - A user can retrieve a run on their own local process - """ - run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.get( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertDictEqual(response.json(), { - "id": str(run.id), - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.local_process.id), - "activity_state": "disabled", - "corpus": None, - "chunks": 1, - "mode": "local", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - def test_retrieve_local_only_current_user(self): - """ - A user cannot retrieve a run on another user's local process - """ - run = WorkerRun.objects.filter(process__creator=self.superuser, process__mode=ProcessMode.Local).first() - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.get( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve_agent(self): - """ - A Ponos agent can retrieve a WorkerRun on a process where it has some assigned tasks - """ - self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) - - # Agent auth is not implemented in CE - self.client.force_authenticate(user=self.agent) - with self.assertNumQueries(3): - 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) - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "mode": "workers", - "chunks": 1, - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - 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) - - # Agent auth is not implemented in CE - self.client.force_authenticate(user=self.agent) - with self.assertNumQueries(1): - response = self.client.get( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - ) - 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(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_update_requires_login(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - - with self.assertNumQueries(0): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @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 they have no admin access on its process project - """ - self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [] - } - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - 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 - """ - run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "parents": [] - } - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], - }) - - def test_update_invalid_id(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_nonexistent_parent(self): - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": ["12341234-1234-1234-1234-123412341234"], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), [ - f"Can't add or update WorkerRun {self.run_1.id} because parents field isn't properly defined. It can be either because" - " one or several UUIDs don't refer to existing WorkerRuns or either because listed WorkerRuns doesn't belong to the" - " same Process than this WorkerRun." - ]) - - def test_update_duplicate_parents(self): - self.client.force_login(self.user) - run_2 = self.process_1.worker_runs.create(version=self.version_2) - - with self.assertNumQueries(4): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [ - str(run_2.id), - str(run_2.id), - ], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "parents": ["The parents of a WorkerRun must be unique."], - }) - - def test_duplicate_parents_signal(self): - """ - Duplicates in WorkerRun.parents are also detected outside of the WorkerRun APIs - """ - run_2 = self.process_1.worker_runs.create( - version=self.version_2, - parents=[], - ) - - run_2.parents = [self.run_1.id, self.run_1.id] - with self.assertRaisesRegex(ValidationError, f"Can't add or update WorkerRun {run_2.id} because it has duplicate parents."): - run_2.parents = [self.run_1.id, self.run_1.id] - run_2.save() - - def test_update_process_id(self): - """ - Process field cannot be updated - """ - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "process_id": str(self.process_2.id), - "parents": [], - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - self.run_1.refresh_from_db() - self.assertEqual(self.run_1.process.id, self.process_1.id) - - def test_update_worker_version_id(self): - """ - Version field cannot be updated - """ - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "worker_version_id": str(self.version_2.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - }, - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - self.run_1.refresh_from_db() - self.assertNotEqual(self.run_1.version_id, self.version_2.id) - - def test_update_configuration(self): - self.client.force_login(self.user) - self.assertEqual(self.run_1.configuration, None) - # Check generated summary, before updating, it should not be that verbose - self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1") - - 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_1.id) - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.run_1.refresh_from_db() - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": { - "id": str(self.configuration_1.id), - "archived": False, - "configuration": {"key": "value"}, - "name": "My config" - }, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1 using configuration 'My config'", - }) - self.assertEqual(self.run_1.configuration.id, self.configuration_1.id) - # Check generated summary, after the update, the configuration should be displayed as well - self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1 using configuration 'My config'") - - def test_update_invalid_configuration(self): - self.client.force_login(self.user) - self.assertEqual(self.run_1.configuration, None) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), - data={"parents": [], "configuration_id": str(self.configuration_2.id)}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) - - def test_update_process_already_started(self): - """ - Update dependencies of a worker run is not possible once the process is started - """ - self.process_1.run() - self.assertTrue(self.process_1.tasks.exists()) - 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)}), - data={ - "parents": [], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], - }) - - def test_update_model_version_not_allowed(self): - """ - The model_version UUID is not allowed when the related version doesn't allow model_usage - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Disabled - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This worker version does not support models."] - }) - - def test_update_unknown_model_version(self): - """ - Cannot use a model version id that doesn't exist - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - random_model_version_uuid = str(uuid.uuid4()) - - with self.assertNumQueries(4): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": random_model_version_uuid, - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] - }) - - @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 - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - - # Create a model version, the user has no access to - 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(3): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(model_version_no_access.id), - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": [f'Invalid pk "{model_version_no_access.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), - ]) - - @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 - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - 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), - (self.model_version_2, None, ModelVersionState.Error), - (self.model_version_2, None, ModelVersionState.Available), - (self.model_version_2, "blah", ModelVersionState.Created), - (self.model_version_2, "blah", ModelVersionState.Error), - # On a public model with no membership - (self.model_version_3, None, ModelVersionState.Created), - (self.model_version_3, None, ModelVersionState.Error), - (self.model_version_3, None, ModelVersionState.Available), - (self.model_version_3, "blah", ModelVersionState.Created), - (self.model_version_3, "blah", ModelVersionState.Error), - ] - - 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(4): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(model_version.id), - "parents": [], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "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) - version = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create( - version=version, - parents=[], - ) - self.model_version_1.state = ModelVersionState.Error - self.model_version_1.save() - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This ModelVersion is not in an Available state."] - }) - - def test_update_model_archived(self): - self.client.force_login(self.user) - version = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create(version=version) - self.model_1.archived = datetime.now(timezone.utc) - self.model_1.save() - - with self.assertNumQueries(5): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This ModelVersion is part of an archived model."], - }) - - def test_update_model_version_id(self): - """ - Update the worker run by adding a model_version with a worker version that supports it - """ - self.client.force_login(self.user) - version_with_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Supported - ) - run = self.process_1.worker_runs.create( - version=version_with_model, - parents=[], - ) - self.assertEqual(run.model_version, None) - # Check generated summary, before updating, there should be only information about the worker version - self.assertEqual(run.summary, "Worker Recognizer @ version 2") - - model_versions = [ - # Version on a model with contributor access - self.model_version_1, - # Available version with tag on a model with guest access - self.model_version_2, - # Available version with tag on a public model - self.model_version_3, - ] - for model_version in model_versions: - with self.subTest(model_version=model_version): - with self.assertNumQueries(6): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(model_version.id), - "parents": [], - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - run.refresh_from_db() - self.assertEqual(response.json(), { - "id": str(run.id), - "configuration": None, - "model_version": { - "id": str(model_version.id), - "configuration": {}, - "model": { - "id": str(model_version.model.id), - "name": model_version.model.name - }, - "size": 8, - "state": "available", - "tag": model_version.tag, - }, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(version_with_model.id), - "configuration": {"test": "test2"}, - "docker_image_iid": None, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Supported.value, - "revision_url": None, - "version": version_with_model.version, - "tag": None, - "created": version_with_model.created.isoformat().replace("+00:00", "Z"), - "state": "created", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}", - }) - self.assertEqual(run.model_version_id, model_version.id) - self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}") - - def test_update_configuration_and_model_version(self): - """ - Update the worker run by adding both a model_version and a worker configuration - """ - self.client.force_login(self.user) - version_with_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create( - version=version_with_model, - parents=[], - ) - self.assertIsNone(run.model_version) - self.assertIsNone(run.configuration) - # Check generated summary, before updating, there should be only information about the worker version - self.assertEqual(run.summary, "Worker Recognizer @ version 2") - - with self.assertNumQueries(7): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - "configuration_id": str(self.configuration_1.id), - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - run.refresh_from_db() - self.assertEqual(response.json(), { - "id": str(run.id), - "configuration": { - "id": str(self.configuration_1.id), - "archived": False, - "configuration": {"key": "value"}, - "name": "My config" - }, - "model_version": { - "id": str(self.model_version_1.id), - "configuration": {}, - "model": { - "id": str(self.model_1.id), - "name": "My model" - }, - "size": 8, - "state": "available", - "tag": None - }, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(version_with_model.id), - "configuration": {"test": "test2"}, - "docker_image_iid": None, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Required.value, - "revision_url": None, - "version": version_with_model.version, - "tag": None, - "created": version_with_model.created.isoformat().replace("+00:00", "Z"), - "state": "created", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'", - }) - self.assertEqual(run.model_version_id, self.model_version_1.id) - # Check generated summary, after updating, there should be information about the model loaded - self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {self.model_version_1.model.name} @ {str(self.model_version_1.id)[:6]} using configuration '{self.configuration_1.name}'") - - def test_update(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - self.client.force_login(self.user) - - with self.assertNumQueries(7): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.run_1.refresh_from_db() - self.assertDictEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [str(run_2.id)], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - def test_update_unique(self): - self.client.force_login(self.user) - self.version_1.model_usage = FeatureUsage.Required - self.version_1.save() - cases = [ - (None, None), - (None, self.configuration_1), - (self.model_version_1, None), - (self.model_version_1, self.configuration_1), - ] - for model_version, configuration in cases: - with self.subTest(model_version=model_version, configuration=configuration): - # Erase any previous failures - self.process_1.worker_runs.exclude(id=self.run_1.id).delete() - - self.run_1.model_version = model_version - self.run_1.configuration = configuration - self.run_1.save() - - # Ensure the other run has different values before updating to avoid conflicts - run_2 = self.process_1.worker_runs.create( - version=self.version_1, - model_version=None if model_version else self.model_version_1, - configuration=None if configuration else self.configuration_1, - ) - - # Having a model version or a configuration adds one query for each - query_count = 4 + bool(model_version) + bool(configuration) - - with self.assertNumQueries(query_count): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - # Update the second worker run to the first worker run's values to cause a conflict - "model_version_id": str(model_version.id) if model_version else None, - "configuration_id": str(configuration.id) if configuration else None, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], - }) - - 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) - - # Agent auth is not implemented in CE - self.client.force_authenticate(user=self.agent) - with self.assertNumQueries(1): - response = self.client.put( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), { - "detail": "You do not have an admin access to this process.", - }) - - def test_partial_update_requires_login(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - - with self.assertNumQueries(0): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - @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 they have no admin access on its process project - """ - self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) - self.client.force_login(self.user) - - with self.assertNumQueries(3): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - 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): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - - self.client.force_login(self.user) - with self.assertNumQueries(3): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_partial_update_local(self): - """ - A user cannot update a worker run on a local process - """ - run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "parents": [] - } - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], - }) - - def test_partial_update_nonexistent_parent(self): - self.client.force_login(self.user) - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": ["12341234-1234-1234-1234-123412341234"], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), [ - f"Can't add or update WorkerRun {self.run_1.id} because parents field isn't properly defined. It can be either because" - " one or several UUIDs don't refer to existing WorkerRuns or either because listed WorkerRuns doesn't belong to the" - " same Process than this WorkerRun." - ]) - - def test_partial_update_process_id(self): - """ - Process field cannot be updated - """ - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "process_id": str(self.process_2.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - self.run_1.refresh_from_db() - self.assertEqual(self.run_1.process.id, self.process_1.id) - - def test_partial_update_worker_version_id(self): - """ - Version field cannot be updated - """ - self.client.force_login(self.user) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "worker_version_id": str(self.version_2.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - self.run_1.refresh_from_db() - self.assertNotEqual(self.run_1.version_id, self.version_2.id) - - def test_partial_update_configuration(self): - self.client.force_login(self.user) - self.assertEqual(self.run_1.configuration, None) - self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1") - - 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_1.id) - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.run_1.refresh_from_db() - self.assertEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": { - "id": str(self.configuration_1.id), - "archived": False, - "configuration": {"key": "value"}, - "name": "My config" - }, - "model_version": None, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1 using configuration 'My config'", - }) - self.assertEqual(self.run_1.configuration.id, self.configuration_1.id) - self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1 using configuration 'My config'") - - def test_partial_update_invalid_configuration(self): - self.client.force_login(self.user) - self.assertEqual(self.run_1.configuration, None) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), - data={"configuration_id": str(self.configuration_2.id)}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) - - def test_partial_update_process_already_started(self): - """ - Update dependencies of a worker run is not possible once the process is started - """ - self.process_1.run() - self.assertTrue(self.process_1.tasks.exists()) - 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)}), - data={ - "parents": [], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], - }) - - def test_partial_update_model_version_not_allowed(self): - """ - The model_version UUID is not allowed when the related version doesn't allow model_usage - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Disabled - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This worker version does not support models."] - }) - - def test_partial_update_unknown_model_version(self): - """ - Cannot use a model version id that doesn't exist - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - random_model_version_uuid = str(uuid.uuid4()) - - with self.assertNumQueries(4): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": random_model_version_uuid, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] - }) - - @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 - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - parents=[], - ) - - # Create a model version, the user has no access to - 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(3): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(model_version_no_access.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": [f'Invalid pk "{model_version_no_access.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), - ]) - - @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 - """ - self.client.force_login(self.user) - version_no_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run_2 = self.process_1.worker_runs.create( - version=version_no_model, - 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), - (self.model_version_2, None, ModelVersionState.Error), - (self.model_version_2, None, ModelVersionState.Available), - (self.model_version_2, "blah", ModelVersionState.Created), - (self.model_version_2, "blah", ModelVersionState.Error), - # On a public model with no membership - (self.model_version_3, None, ModelVersionState.Created), - (self.model_version_3, None, ModelVersionState.Error), - (self.model_version_3, None, ModelVersionState.Available), - (self.model_version_3, "blah", ModelVersionState.Created), - (self.model_version_3, "blah", ModelVersionState.Error), - ] - - 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(4): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - "model_version_id": str(model_version.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "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) - version = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create( - version=version, - parents=[], - ) - self.model_version_1.state = ModelVersionState.Error - self.model_version_1.save() - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - "parents": [] - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This ModelVersion is not in an Available state."], - }) - - def test_partial_update_model_archived(self): - self.client.force_login(self.user) - version = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create(version=version) - self.model_1.archived = datetime.now(timezone.utc) - self.model_1.save() - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "model_version_id": ["This ModelVersion is part of an archived model."], - }) - - def test_partial_update_model_version(self): - """ - Update the worker run by adding a model_version with a worker version that supports it - """ - self.client.force_login(self.user) - version_with_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create( - version=version_with_model, - parents=[], - ) - self.assertIsNone(run.model_version_id) - self.assertEqual(run.summary, "Worker Recognizer @ version 2") - - model_versions = [ - # Version on a model with contributor access - self.model_version_1, - # Available version with tag on a model with guest access - self.model_version_2, - # Available version with tag on a public model - self.model_version_3, - ] - for model_version in model_versions: - with self.subTest(model_version=model_version): - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(model_version.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - run.refresh_from_db() - self.assertEqual(response.json(), { - "id": str(run.id), - "configuration": None, - "model_version": { - "id": str(model_version.id), - "configuration": {}, - "model": { - "id": str(model_version.model.id), - "name": model_version.model.name - }, - "size": 8, - "state": "available", - "tag": model_version.tag, - }, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(version_with_model.id), - "configuration": {"test": "test2"}, - "docker_image_iid": None, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Required.value, - "revision_url": None, - "version": version_with_model.version, - "tag": None, - "created": version_with_model.created.isoformat().replace("+00:00", "Z"), - "state": "created", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}", - }) - self.assertEqual(run.model_version_id, model_version.id) - self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}") - - def test_partial_update_model_version_with_configuration(self): - """ - Updating the worker run by adding a model_version with a worker version - doesn't erase previously loaded worker configuration - """ - self.client.force_login(self.user) - version_with_model = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"}, - model_usage=FeatureUsage.Required - ) - run = self.process_1.worker_runs.create( - version=version_with_model, - parents=[], - configuration=self.configuration_1 - ) - self.assertEqual(run.model_version_id, None) - - with self.assertNumQueries(6): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={ - "model_version_id": str(self.model_version_1.id), - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - run.refresh_from_db() - self.assertEqual(response.json(), { - "id": str(run.id), - "configuration": { - "archived": False, - "configuration": {"key": "value"}, - "id": str(self.configuration_1.id), - "name": "My config" - }, - "model_version": { - "id": str(self.model_version_1.id), - "configuration": {}, - "model": { - "id": str(self.model_1.id), - "name": "My model" - }, - "size": 8, - "state": "available", - "tag": None - }, - "parents": [], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(version_with_model.id), - "configuration": {"test": "test2"}, - "docker_image_iid": None, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Required.value, - "revision_url": None, - "version": version_with_model.version, - "tag": None, - "created": version_with_model.created.isoformat().replace("+00:00", "Z"), - "state": "created", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'", - }) - self.assertEqual(run.model_version_id, self.model_version_1.id) - self.assertEqual(run.configuration_id, self.configuration_1.id) - - def test_partial_update(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[], - ) - self.client.force_login(self.user) - - with self.assertNumQueries(7): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - data={ - "parents": [str(run_2.id)], - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.run_1.refresh_from_db() - self.assertDictEqual(response.json(), { - "id": str(self.run_1.id), - "configuration": None, - "model_version": None, - "parents": [str(run_2.id)], - "process": { - "id": str(self.process_1.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - }, - "worker_version": { - "id": str(self.version_1.id), - "configuration": {"test": 42}, - "docker_image_iid": self.version_1.docker_image_iid, - "gpu_usage": "disabled", - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 1, - "tag": None, - "created": self.version_1.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker_1.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "use_gpu": False, - "summary": "Worker Recognizer @ version 1", - }) - - def test_partial_update_unique(self): - self.client.force_login(self.user) - self.version_1.model_usage = FeatureUsage.Required - self.version_1.save() - cases = [ - (None, None), - (None, self.configuration_1), - (self.model_version_1, None), - (self.model_version_1, self.configuration_1), - ] - for model_version, configuration in cases: - with self.subTest(model_version=model_version, configuration=configuration): - # Erase any previous failures - self.process_1.worker_runs.exclude(id=self.run_1.id).delete() - - self.run_1.model_version = model_version - self.run_1.configuration = configuration - self.run_1.save() - - # Ensure the other run has different values before updating to avoid conflicts - run_2 = self.process_1.worker_runs.create( - version=self.version_1, - model_version=None if model_version else self.model_version_1, - configuration=None if configuration else self.configuration_1, - ) - - # Having a model version or a configuration adds one query for each - query_count = 4 + bool(model_version) + bool(configuration) - - with self.assertNumQueries(query_count): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), - data={ - # Update the second worker run to the first worker run's values to cause a conflict - "model_version_id": str(model_version.id) if model_version else None, - "configuration_id": str(configuration.id) if configuration else None, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), { - "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], - }) - - 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) - - # Agent auth is not implemented in CE - self.client.force_authenticate(user=self.agent) - with self.assertNumQueries(1): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), { - "detail": "You do not have an admin access to this process.", - }) - - def test_delete_requires_login(self): - with self.assertNumQueries(0): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_invalid_id(self): - self.client.force_login(self.user) - with self.assertNumQueries(3): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}) - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_no_worker_execution_right(self): - """ - A user can delete a worker run with no right on its worker but an admin access to the process project - """ - self.worker_1.memberships.update(level=Role.Guest.value) - self.client.force_login(self.user) - - with self.assertNumQueries(6): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - with self.assertRaises(WorkerRun.DoesNotExist): - self.run_1.refresh_from_db() - - def test_delete_local(self): - """ - A user cannot delete a worker run on a local process - """ - run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) - self.client.force_login(self.user) - - with self.assertNumQueries(4): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), ["WorkerRuns can only be deleted from Workers or Dataset processes."]) - - def test_delete(self): - self.client.force_login(self.user) - - with self.assertNumQueries(6): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - with self.assertRaises(WorkerRun.DoesNotExist): - self.run_1.refresh_from_db() - - def test_delete_with_children(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - version_3 = WorkerVersion.objects.create( - worker=self.worker_1, - version=3, - configuration={"test": "test3"} - ) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[self.run_1.id], - ) - run_3 = self.process_1.worker_runs.create( - version=version_3, - parents=[self.run_1.id, run_2.id], - ) - - self.assertTrue(self.run_1.id in run_2.parents) - self.assertTrue(self.run_1.id in run_3.parents) - self.client.force_login(self.user) - - with self.assertNumQueries(6): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - with self.assertRaises(WorkerRun.DoesNotExist): - self.run_1.refresh_from_db() - run_2.refresh_from_db() - run_3.refresh_from_db() - self.assertEqual(run_2.parents, []) - self.assertEqual(run_3.parents, [run_2.id]) - - def test_delete_started_process(self): - """ - A user shouldn't be able to delete the worker run of a started process - """ - self.client.force_login(self.user) - self.process_1.run() - self.process_1.tasks.update(state=State.Running) - - with self.assertNumQueries(3): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - self.assertEqual(response.json(), ["WorkerRuns cannot be deleted from a process that has already started."]) - - 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) - - # Agent auth is not implemented in CE - self.client.force_authenticate(user=self.agent) - with self.assertNumQueries(1): - response = self.client.delete( - reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(response.json(), { - "detail": "You do not have an admin access to this process.", - }) - - def test_build_task_no_parent(self): - task, parent_slugs = self.run_1.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json") - - self.assertEqual(task.slug, f"reco_{str(self.run_1.id)[0:6]}") - self.assertEqual(task.image, self.version_1.docker_image_iid) - self.assertEqual(task.command, None) - self.assertEqual(task.shm_size, None) - self.assertEqual(parent_slugs, ["import"]) - self.assertEqual(task.env, { - "ARKINDEX_PROCESS_ID": "12345", - "ARKINDEX_TASK_TOKEN": str(task.token), - "TASK_ELEMENTS": "/data/import/elements.json", - "ARKINDEX_WORKER_RUN_ID": str(self.run_1.id), - }) - - def test_build_task_with_chunk(self): - task, parent_slugs = self.run_1.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json", chunk=4) - - self.assertEqual(task.slug, f"reco_{str(self.run_1.id)[0:6]}_4") - self.assertEqual(task.image, self.version_1.docker_image_iid) - self.assertEqual(task.command, None) - self.assertEqual(task.shm_size, None) - self.assertEqual(parent_slugs, ["import"]) - self.assertEqual(task.env, { - "ARKINDEX_PROCESS_ID": "12345", - "ARKINDEX_TASK_TOKEN": str(task.token), - "ARKINDEX_TASK_CHUNK": "4", - "TASK_ELEMENTS": "/data/import/elements.json", - "ARKINDEX_WORKER_RUN_ID": str(self.run_1.id), - }) - - def test_build_task_with_parent(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - version_2.docker_image_iid = "evaunit:latest" - version_2.state = WorkerVersionState.Available - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[self.run_1.id], - ) - - task, parent_slugs = run_2.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json") - - self.assertEqual(task.slug, f"reco_{str(run_2.id)[0:6]}") - self.assertEqual(task.image, version_2.docker_image_iid) - self.assertEqual(task.command, None) - self.assertEqual(task.shm_size, None) - self.assertEqual(parent_slugs, [f"reco_{str(self.run_1.id)[0:6]}"]) - self.assertEqual(task.env, { - "ARKINDEX_PROCESS_ID": "12345", - "ARKINDEX_TASK_TOKEN": str(task.token), - "TASK_ELEMENTS": "/data/import/elements.json", - "ARKINDEX_WORKER_RUN_ID": str(run_2.id), - }) - - def test_build_task_with_parent_and_chunk(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - version_2.docker_image_iid = "evaunit:latest" - version_2.state = WorkerVersionState.Available - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[self.run_1.id], - ) - - task, parent_slugs = run_2.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json", chunk=4) - - self.assertEqual(task.slug, f"reco_{str(run_2.id)[0:6]}_4") - self.assertEqual(task.image, version_2.docker_image_iid) - self.assertEqual(task.command, None) - self.assertEqual(task.shm_size, None) - self.assertEqual(parent_slugs, [f"reco_{str(self.run_1.id)[0:6]}_4"]) - self.assertEqual(task.env, { - "ARKINDEX_PROCESS_ID": "12345", - "ARKINDEX_TASK_TOKEN": str(task.token), - "ARKINDEX_TASK_CHUNK": "4", - "TASK_ELEMENTS": "/data/import/elements.json", - "ARKINDEX_WORKER_RUN_ID": str(run_2.id), - }) - - def test_build_task_shm_size(self): - self.version_1.configuration = { - "docker": { - "shm_size": 505, - } - } - task, parent_slugs = self.run_1.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json") - - self.assertEqual(task.slug, f"reco_{str(self.run_1.id)[0:6]}") - self.assertEqual(task.image, self.version_1.docker_image_iid) - self.assertEqual(task.command, None) - self.assertEqual(task.shm_size, 505) - self.assertEqual(parent_slugs, ["import"]) - self.assertEqual(task.env, { - "ARKINDEX_PROCESS_ID": "12345", - "ARKINDEX_TASK_TOKEN": str(task.token), - "TASK_ELEMENTS": "/data/import/elements.json", - "ARKINDEX_WORKER_RUN_ID": str(self.run_1.id), - }) - - def test_build_task_unavailable_version(self): - version_2 = WorkerVersion.objects.create( - worker=self.worker_1, - version=2, - configuration={"test": "test2"} - ) - version_2.docker_image_iid = "evaunit:latest" - self.assertEqual(version_2.state, WorkerVersionState.Created) - run_2 = self.process_1.worker_runs.create( - version=version_2, - parents=[self.run_1.id], - ) - - with self.assertRaisesRegex( - AssertionError, - f"Worker Version {version_2.id} is not available and cannot be used to build a task." - ): - run_2.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json") - - def test_build_task_unavailable_model_version(self): - self.model_version_1.state = ModelVersionState.Created - self.model_version_1.save() - self.run_1.model_version = self.model_version_1 - self.run_1.save() - with self.assertRaisesRegex( - AssertionError, - f"ModelVersion {self.model_version_1.id} is not available and cannot be used to build a task." - ): - self.run_1.build_task(self.process_1, ENV.copy(), "import", "/data/import/elements.json") diff --git a/arkindex/process/tests/test_workerruns_use_gpu.py b/arkindex/process/tests/test_workerruns_use_gpu.py deleted file mode 100644 index 79704d3cf80653b5210d34b9c889286eb76a8c54..0000000000000000000000000000000000000000 --- a/arkindex/process/tests/test_workerruns_use_gpu.py +++ /dev/null @@ -1,266 +0,0 @@ -from django.urls import reverse -from rest_framework import status - -from arkindex.ponos.models import Farm -from arkindex.process.models import FeatureUsage, ProcessMode, Worker, WorkerRun, WorkerVersion, WorkerVersionState -from arkindex.project.tests import FixtureAPITestCase - - -class TestWorkerRunsGPU(FixtureAPITestCase): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.farm = Farm.objects.first() - cls.test_process = cls.corpus.processes.create( - creator=cls.user, - mode=ProcessMode.Workers, - farm=cls.farm, - ) - cls.worker = Worker.objects.get(slug="reco") - cls.version_gpu_required = WorkerVersion.objects.create( - worker=cls.worker, - configuration={"pilot": "ikari shinji"}, - state=WorkerVersionState.Available, - model_usage=FeatureUsage.Disabled, - docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", - gpu_usage=FeatureUsage.Required, - version=2 - ) - cls.version_gpu_supported = WorkerVersion.objects.create( - worker=cls.worker, - configuration={"pilot": "ayanami rei"}, - state=WorkerVersionState.Available, - model_usage=FeatureUsage.Disabled, - docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", - gpu_usage=FeatureUsage.Supported, - version=3 - ) - cls.version_gpu_disabled = WorkerVersion.objects.create( - worker=cls.worker, - configuration={"pilot": "soryu asuka langley"}, - state=WorkerVersionState.Available, - model_usage=FeatureUsage.Disabled, - docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", - gpu_usage=FeatureUsage.Disabled, - version=4 - ) - - def test_create_gpu_usage_auto(self): - """ - use_gpu on the worker run is set automatically depending on the worker version's gpu_usage - """ - self.client.force_login(self.user) - - cases = [ - (self.version_gpu_disabled, False), - (self.version_gpu_supported, False), - (self.version_gpu_required, True) - ] - - for worker_version, use_gpu in cases: - with self.subTest(worker_version=worker_version, use_gpu=use_gpu): - with self.assertNumQueries(6): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.test_process.id)}), - {"worker_version_id": str(worker_version.id), "parents": []}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - data = response.json() - pk = data.pop("id") - self.assertDictEqual(data, { - "worker_version": { - "id": str(worker_version.id), - "configuration": worker_version.configuration, - "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", - "gpu_usage": worker_version.gpu_usage.value, - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": worker_version.version, - "tag": None, - "created": worker_version.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.test_process.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "summary": f"Worker Recognizer @ version {worker_version.version}", - "use_gpu": use_gpu - }) - run = WorkerRun.objects.get(pk=pk) - self.assertEqual(run.use_gpu, use_gpu) - - def test_create_use_gpu_ignored(self): - """ - if "use_gpu" is being sent when creating a worker run, it is ignored - """ - self.client.force_login(self.user) - with self.assertNumQueries(6): - response = self.client.post( - reverse("api:worker-run-list", kwargs={"pk": str(self.test_process.id)}), - {"worker_version_id": str(self.version_gpu_required.id), "parents": [], "use_gpu": False}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - data = response.json() - pk = data.pop("id") - self.assertDictEqual(data, { - "worker_version": { - "id": str(self.version_gpu_required.id), - "configuration": {"pilot": "ikari shinji"}, - "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", - "gpu_usage": FeatureUsage.Required.value, - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": 2, - "tag": None, - "created": self.version_gpu_required.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.test_process.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "summary": "Worker Recognizer @ version 2", - "use_gpu": True - }) - run = WorkerRun.objects.get(pk=pk) - self.assertEqual(run.use_gpu, True) - - def test_update_use_gpu_errors(self): - self.client.force_login(self.user) - - cases = [ - (self.version_gpu_disabled, True, "This worker version does not support GPU usage."), - (self.version_gpu_required, False, "This worker version requires GPU usage.") - ] - - for worker_version, use_gpu, error_message in cases: - with self.subTest(worker_version=worker_version, use_gpu=use_gpu, error_message=error_message): - run = WorkerRun.objects.create( - process=self.test_process, - version=worker_version, - parents=[] - ) - self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) - with self.assertNumQueries(3): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={"use_gpu": use_gpu} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.json(), {"use_gpu": [error_message]}) - - run.refresh_from_db() - self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) - - def test_update_use_gpu(self): - self.client.force_login(self.user) - - cases = [ - (self.version_gpu_disabled, False), - (self.version_gpu_required, True), - (self.version_gpu_supported, False), - (self.version_gpu_supported, True) - ] - - for worker_version, use_gpu in cases: - with self.subTest(worker_version=worker_version, use_gpu=use_gpu): - self.test_process.worker_runs.all().delete() - run = WorkerRun.objects.create( - process=self.test_process, - version=worker_version, - parents=[] - ) - self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) - - with self.assertNumQueries(5): - response = self.client.patch( - reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), - data={"use_gpu": use_gpu} - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - data = response.json() - self.assertDictEqual(data, { - "id": str(run.id), - "worker_version": { - "id": str(worker_version.id), - "configuration": worker_version.configuration, - "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", - "gpu_usage": worker_version.gpu_usage.value, - "model_usage": FeatureUsage.Disabled.value, - "revision_url": None, - "version": worker_version.version, - "tag": None, - "created": worker_version.created.isoformat().replace("+00:00", "Z"), - "state": "available", - "worker": { - "id": str(self.worker.id), - "name": "Recognizer", - "slug": "reco", - "type": "recognizer", - "description": "", - "repository_url": None, - "archived": False, - } - }, - "parents": [], - "model_version": None, - "configuration": None, - "process": { - "id": str(self.test_process.id), - "activity_state": "disabled", - "corpus": str(self.corpus.id), - "element": None, - "element_type": None, - "folder_type": None, - "prefix": None, - "chunks": 1, - "mode": "workers", - "name": None, - "state": "unscheduled", - "use_cache": False, - }, - "summary": f"Worker Recognizer @ version {worker_version.version}", - "use_gpu": use_gpu - }) - run.refresh_from_db() - self.assertEqual(run.use_gpu, use_gpu) diff --git a/arkindex/process/tests/worker_runs/__init__.py b/arkindex/process/tests/worker_runs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/arkindex/process/tests/worker_runs/test_build_task.py b/arkindex/process/tests/worker_runs/test_build_task.py new file mode 100644 index 0000000000000000000000000000000000000000..51c8d672036b28ae34b19aced225b83778c1487a --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_build_task.py @@ -0,0 +1,175 @@ +from arkindex.ponos.models import Farm +from arkindex.process.models import ProcessMode, WorkerVersion, WorkerVersionState +from arkindex.project.tests import FixtureAPITestCase +from arkindex.training.models import Model, ModelVersion, ModelVersionState +from arkindex.users.models import Role + +ENV = { + "ARKINDEX_PROCESS_ID": "12345" +} + + +class TestWorkerRunsBuildTask(FixtureAPITestCase): + """ + Test task creation from worker runs + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.farm = Farm.objects.first() + cls.process = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version = WorkerVersion.objects.get(worker__slug="reco") + cls.worker = cls.version.worker + cls.worker_run = cls.process.worker_runs.create(version=cls.version, parents=[]) + + # Model and Model version setup + cls.model_1 = Model.objects.create(name="My model") + cls.model_1.memberships.create(user=cls.user, level=Role.Contributor.value) + cls.model_version = ModelVersion.objects.create( + model=cls.model_1, + state=ModelVersionState.Available, + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + def test_build_task_no_parent(self): + task, parent_slugs = self.worker_run.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json") + + self.assertEqual(task.slug, f"reco_{str(self.worker_run.id)[0:6]}") + self.assertEqual(task.image, self.version.docker_image_iid) + self.assertEqual(task.command, None) + self.assertEqual(task.shm_size, None) + self.assertEqual(parent_slugs, ["import"]) + self.assertEqual(task.env, { + "ARKINDEX_PROCESS_ID": "12345", + "ARKINDEX_TASK_TOKEN": str(task.token), + "TASK_ELEMENTS": "/data/import/elements.json", + "ARKINDEX_WORKER_RUN_ID": str(self.worker_run.id), + }) + + def test_build_task_with_chunk(self): + task, parent_slugs = self.worker_run.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json", chunk=4) + + self.assertEqual(task.slug, f"reco_{str(self.worker_run.id)[0:6]}_4") + self.assertEqual(task.image, self.version.docker_image_iid) + self.assertEqual(task.command, None) + self.assertEqual(task.shm_size, None) + self.assertEqual(parent_slugs, ["import"]) + self.assertEqual(task.env, { + "ARKINDEX_PROCESS_ID": "12345", + "ARKINDEX_TASK_TOKEN": str(task.token), + "ARKINDEX_TASK_CHUNK": "4", + "TASK_ELEMENTS": "/data/import/elements.json", + "ARKINDEX_WORKER_RUN_ID": str(self.worker_run.id), + }) + + def test_build_task_with_parent(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker, + version=2, + configuration={"test": "test2"} + ) + version_2.docker_image_iid = "evaunit:latest" + version_2.state = WorkerVersionState.Available + run_2 = self.process.worker_runs.create( + version=version_2, + parents=[self.worker_run.id], + ) + + task, parent_slugs = run_2.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json") + + self.assertEqual(task.slug, f"reco_{str(run_2.id)[0:6]}") + self.assertEqual(task.image, version_2.docker_image_iid) + self.assertEqual(task.command, None) + self.assertEqual(task.shm_size, None) + self.assertEqual(parent_slugs, [f"reco_{str(self.worker_run.id)[0:6]}"]) + self.assertEqual(task.env, { + "ARKINDEX_PROCESS_ID": "12345", + "ARKINDEX_TASK_TOKEN": str(task.token), + "TASK_ELEMENTS": "/data/import/elements.json", + "ARKINDEX_WORKER_RUN_ID": str(run_2.id), + }) + + def test_build_task_with_parent_and_chunk(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker, + version=2, + configuration={"test": "test2"} + ) + version_2.docker_image_iid = "evaunit:latest" + version_2.state = WorkerVersionState.Available + run_2 = self.process.worker_runs.create( + version=version_2, + parents=[self.worker_run.id], + ) + + task, parent_slugs = run_2.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json", chunk=4) + + self.assertEqual(task.slug, f"reco_{str(run_2.id)[0:6]}_4") + self.assertEqual(task.image, version_2.docker_image_iid) + self.assertEqual(task.command, None) + self.assertEqual(task.shm_size, None) + self.assertEqual(parent_slugs, [f"reco_{str(self.worker_run.id)[0:6]}_4"]) + self.assertEqual(task.env, { + "ARKINDEX_PROCESS_ID": "12345", + "ARKINDEX_TASK_TOKEN": str(task.token), + "ARKINDEX_TASK_CHUNK": "4", + "TASK_ELEMENTS": "/data/import/elements.json", + "ARKINDEX_WORKER_RUN_ID": str(run_2.id), + }) + + def test_build_task_shm_size(self): + self.version.configuration = { + "docker": { + "shm_size": 505, + } + } + task, parent_slugs = self.worker_run.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json") + + self.assertEqual(task.slug, f"reco_{str(self.worker_run.id)[0:6]}") + self.assertEqual(task.image, self.version.docker_image_iid) + self.assertEqual(task.command, None) + self.assertEqual(task.shm_size, 505) + self.assertEqual(parent_slugs, ["import"]) + self.assertEqual(task.env, { + "ARKINDEX_PROCESS_ID": "12345", + "ARKINDEX_TASK_TOKEN": str(task.token), + "TASK_ELEMENTS": "/data/import/elements.json", + "ARKINDEX_WORKER_RUN_ID": str(self.worker_run.id), + }) + + def test_build_task_unavailable_version(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker, + version=2, + configuration={"test": "test2"} + ) + version_2.docker_image_iid = "evaunit:latest" + self.assertEqual(version_2.state, WorkerVersionState.Created) + run_2 = self.process.worker_runs.create( + version=version_2, + parents=[self.worker_run.id], + ) + + with self.assertRaisesRegex( + AssertionError, + f"Worker Version {version_2.id} is not available and cannot be used to build a task." + ): + run_2.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json") + + def test_build_task_unavailable_model_version(self): + self.model_version.state = ModelVersionState.Created + self.model_version.save() + self.worker_run.model_version = self.model_version + self.worker_run.save() + with self.assertRaisesRegex( + AssertionError, + f"ModelVersion {self.model_version.id} is not available and cannot be used to build a task." + ): + self.worker_run.build_task(self.process, ENV.copy(), "import", "/data/import/elements.json") diff --git a/arkindex/process/tests/worker_runs/test_create.py b/arkindex/process/tests/worker_runs/test_create.py new file mode 100644 index 0000000000000000000000000000000000000000..0cf94a07eb15d8e4a55c9a35e056c5a2575b04f5 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_create.py @@ -0,0 +1,603 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import call, patch + +from django.db import transaction +from django.urls import reverse +from rest_framework import status + +from arkindex.ponos.models import Farm +from arkindex.process.models import ( + FeatureUsage, + ProcessMode, + WorkerRun, + WorkerVersion, + WorkerVersionState, +) +from arkindex.project.tests import FixtureAPITestCase +from arkindex.training.models import Model, ModelVersion, ModelVersionState +from arkindex.users.models import Role + + +class TestWorkerRunsCreate(FixtureAPITestCase): + """ + Test worker runs create endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + cls.configuration_1 = cls.worker_1.configurations.create(name="My config", configuration={"key": "value"}) + worker_version = WorkerVersion.objects.exclude(worker=cls.version_1.worker).first() + cls.configuration_2 = worker_version.worker.configurations.create(name="Config") + cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) + + # Model and Model version setup + cls.model_1 = Model.objects.create(name="My model") + cls.model_1.memberships.create(user=cls.user, level=Role.Contributor.value) + cls.model_version_1 = ModelVersion.objects.create( + model=cls.model_1, + state=ModelVersionState.Available, + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + cls.version_gpu_required = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "ikari shinji"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Required, + version=2 + ) + cls.version_gpu_supported = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "ayanami rei"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Supported, + version=3 + ) + cls.version_gpu_disabled = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "soryu asuka langley"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Disabled, + version=4 + ) + + def test_create_requires_login(self): + with self.assertNumQueries(0): + 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": []}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.assertDictEqual(response.json(), {"detail": "Authentication credentials were not provided."}) + + @patch("arkindex.users.utils.get_max_level", return_value=Role.Guest.value) + def test_create_no_execution_right(self, max_level_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(5): + 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": []}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), {"worker_version_id": ["You do not have an execution access to this worker."]}) + + self.assertListEqual(max_level_mock.call_args_list, [ + call(self.user, self.worker_1) + ]) + + def test_create_invalid_version(self): + self.client.force_login(self.user) + version_id = uuid.uuid4() + + 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}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "worker_version_id": [f'Invalid pk "{version_id}" - object does not exist.'], + }) + + def test_create_unique(self): + self.client.force_login(self.user) + self.version_1.model_usage = FeatureUsage.Required + self.version_1.save() + cases = [ + (None, None), + (None, self.configuration_1), + (self.model_version_1, None), + (self.model_version_1, self.configuration_1), + ] + for model_version, configuration in cases: + with self.subTest(model_version=model_version, configuration=configuration): + self.run_1.model_version = model_version + self.run_1.configuration = configuration + self.run_1.save() + + # 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.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)}), + data={ + "worker_version_id": str(self.version_1.id), + "model_version_id": str(model_version.id) if model_version else None, + "configuration_id": str(configuration.id) if configuration else None, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], + }) + + def test_create_unavailable_version(self): + self.version_1.state = WorkerVersionState.Error + self.version_1.save() + self.client.force_login(self.user) + + with self.assertNumQueries(5): + 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": []}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), {"worker_version_id": ["This WorkerVersion is not in an Available state."]}) + + def test_create_archived_worker(self): + self.worker_1.archived = datetime.now(timezone.utc) + self.worker_1.save() + self.client.force_login(self.user) + + with self.assertNumQueries(5): + 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": []}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), {"worker_version_id": ["This WorkerVersion is part of an archived worker."]}) + + def test_create_archived_model(self): + self.model_1.archived = datetime.now(timezone.utc) + self.model_1.save() + self.version_1.model_usage = FeatureUsage.Supported + self.version_1.save() + self.client.force_login(self.user) + + 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), "model_version_id": str(self.model_version_1.id)}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), {"model_version_id": ["This ModelVersion is part of an archived model."]}) + + def test_create_invalid_process_id(self): + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": uuid.uuid4()}), + data={"worker_version_id": str(self.version_1.id), "parents": []}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.assertEqual(response.json(), {"detail": "No Process matches the given query."}) + + def test_create_invalid_process_mode(self): + self.client.force_login(self.user) + + for mode in set(ProcessMode) - {ProcessMode.Workers, ProcessMode.Dataset, ProcessMode.Local}: + with self.subTest(mode=mode): + self.process_2.mode = mode + self.process_2.save() + + with self.assertNumQueries(5): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), + {"worker_version_id": str(self.version_1.id)}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], + }) + + def test_create_non_corpus_process_mode(self): + process = self.user.processes.get(mode=ProcessMode.Local) + self.client.force_login(self.user) + with self.assertNumQueries(3): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(process.id)}), + {"worker_version_id": str(self.version_1.id), "parents": []}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_create_process_already_started(self): + process = self.corpus.processes.create( + creator=self.user, + mode=ProcessMode.Workers, + farm=self.farm, + ) + process.run() + + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(process.id)}), + data={"worker_version_id": str(self.version_1.id), "parents": []}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], + }) + + def test_create(self): + self.client.force_login(self.user) + + for mode in (ProcessMode.Workers, ProcessMode.Dataset): + self.process_2.worker_runs.filter(version=self.version_1).delete() + + with self.subTest(mode=mode): + self.process_2.mode = mode + self.process_2.save() + + 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), "parents": []}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = response.json() + pk = data.pop("id") + self.assertNotEqual(pk, self.run_1.id) + self.assertDictEqual(data, { + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_2.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": mode.value, + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + run = WorkerRun.objects.get(pk=pk) + # Check generated summary + self.assertEqual(run.summary, "Worker Recognizer @ version 1") + + def test_create_empty(self): + """ + The worker_version_id is required to create a worker run + """ + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertDictEqual(response.json(), { + "worker_version_id": ["This field is required."], + }) + + def test_create_configuration(self): + self.client.force_login(self.user) + + 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_1.id) + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = response.json() + pk = data.pop("id") + self.assertNotEqual(pk, self.run_1.id) + self.assertDictEqual(data, { + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": { + "id": str(self.configuration_1.id), + "archived": False, + "configuration": {"key": "value"}, + "name": "My config" + }, + "process": { + "id": str(self.process_2.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1 using configuration 'My config'", + }) + run = WorkerRun.objects.get(pk=pk) + # Check generated summary + self.assertEqual(run.summary, "Worker Recognizer @ version 1 using configuration 'My config'") + + def test_create_invalid_configuration(self): + self.client.force_login(self.user) + + 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": [], "configuration_id": str(self.configuration_2.id)}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) + + def test_create_summary(self): + self.client.force_login(self.user) + test_version = self.worker_1.versions.create( + version=5, + state=WorkerVersionState.Available, + docker_image_iid="registry.gitlab.com/dead-sea-scrolls:004", + model_usage=FeatureUsage.Supported + ) + + cases = [ + ("eva-01", "revision_url", self.model_version_1, self.configuration_1, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]}) with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), + (None, "revision_url", self.model_version_1, self.configuration_1, f"Worker Recognizer @ {str(test_version.id)[:6]} with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), + ("eva-01", "revision_url", None, self.configuration_1, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]}) using configuration 'My config'"), + (None, "revision_url", self.model_version_1, None, f"Worker Recognizer @ {str(test_version.id)[:6]} with model My model @ {str(self.model_version_1.id)[:6]}"), + ("eva-01", "revision_url", None, None, f"Worker Recognizer @ eva-01 ({str(test_version.id)[:6]})"), + (None, "revision_url", None, None, f"Worker Recognizer @ {str(test_version.id)[:6]}"), + ("eva-01", None, self.model_version_1, self.configuration_1, f"Worker Recognizer @ eva-01 (version 5) with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), + (None, None, self.model_version_1, self.configuration_1, f"Worker Recognizer @ version 5 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'"), + ("eva-01", None, None, self.configuration_1, "Worker Recognizer @ eva-01 (version 5) using configuration 'My config'"), + (None, None, self.model_version_1, None, f"Worker Recognizer @ version 5 with model My model @ {str(self.model_version_1.id)[:6]}"), + ("eva-01", None, None, None, "Worker Recognizer @ eva-01 (version 5)"), + (None, None, None, None, "Worker Recognizer @ version 5"), + ] + + for tag, revision_url, model_version, config, expected_summary in cases: + with self.subTest(tag=tag, model_version=model_version, config=config), transaction.atomic(): + # Clear the process of worker runs + self.process_2.worker_runs.all().delete() + + num_queries = 6 + + test_version.version = None + test_version.tag = tag + test_version.revision_url = revision_url + if not revision_url: + test_version.version = 5 + test_version.save() + + payload = { + "worker_version_id": str(test_version.id), + "parents": [], + } + if model_version: + payload["model_version_id"] = model_version.id + # If there is a model version, it adds a query + num_queries += 1 + if config: + payload["configuration_id"] = config.id + # If there is a configuration, it adds a query + num_queries += 1 + + with self.assertNumQueries(num_queries): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_2.id)}), + data=payload, + ) + if response.status_code == 400: + self.assertDictEqual(response.json(), {}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + created_run = WorkerRun.objects.get(pk=response.json()["id"]) + self.assertEqual(created_run.summary, expected_summary) + + def test_create_gpu_usage_auto(self): + """ + use_gpu on the worker run is set automatically depending on the worker version's gpu_usage + """ + self.client.force_login(self.user) + + cases = [ + (self.version_gpu_disabled, False), + (self.version_gpu_supported, False), + (self.version_gpu_required, True) + ] + + for worker_version, use_gpu in cases: + with self.subTest(worker_version=worker_version, use_gpu=use_gpu): + with self.assertNumQueries(6): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)}), + {"worker_version_id": str(worker_version.id), "parents": []}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = response.json() + pk = data.pop("id") + self.assertDictEqual(data, { + "worker_version": { + "id": str(worker_version.id), + "configuration": worker_version.configuration, + "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", + "gpu_usage": worker_version.gpu_usage.value, + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": worker_version.version, + "tag": None, + "created": worker_version.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "summary": f"Worker Recognizer @ version {worker_version.version}", + "use_gpu": use_gpu + }) + run = WorkerRun.objects.get(pk=pk) + self.assertEqual(run.use_gpu, use_gpu) + + def test_create_use_gpu_ignored(self): + """ + if "use_gpu" is being sent when creating a worker run, it is ignored + """ + self.client.force_login(self.user) + with self.assertNumQueries(6): + response = self.client.post( + reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)}), + {"worker_version_id": str(self.version_gpu_required.id), "parents": [], "use_gpu": False}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = response.json() + pk = data.pop("id") + self.assertDictEqual(data, { + "worker_version": { + "id": str(self.version_gpu_required.id), + "configuration": {"pilot": "ikari shinji"}, + "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", + "gpu_usage": FeatureUsage.Required.value, + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 2, + "tag": None, + "created": self.version_gpu_required.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "summary": "Worker Recognizer @ version 2", + "use_gpu": True + }) + run = WorkerRun.objects.get(pk=pk) + self.assertEqual(run.use_gpu, True) diff --git a/arkindex/process/tests/worker_runs/test_delete.py b/arkindex/process/tests/worker_runs/test_delete.py new file mode 100644 index 0000000000000000000000000000000000000000..a288d868afd7736e671c3cf75c13202bc062ef70 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_delete.py @@ -0,0 +1,170 @@ +from datetime import datetime, timezone + +from django.urls import reverse +from rest_framework import status + +from arkindex.ponos.models import Agent, Farm, State +from arkindex.process.models import ProcessMode, WorkerRun, WorkerVersion +from arkindex.project.tests import FixtureAPITestCase +from arkindex.users.models import Role + + +class TestWorkerRunsDelete(FixtureAPITestCase): + """ + Test worker runs delete endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + cls.version_2 = WorkerVersion.objects.get(worker__slug="dla") + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + + cls.agent = Agent.objects.create( + farm=cls.farm, + hostname="claude", + cpu_cores=42, + cpu_frequency=1e15, + ram_total=99e9, + last_ping=datetime.now(timezone.utc), + ) + # Add custom attributes to make the agent usable as an authenticated user + cls.agent.is_agent = True + cls.agent.is_anonymous = False + + def test_delete_requires_login(self): + with self.assertNumQueries(0): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_id(self): + self.client.force_login(self.user) + with self.assertNumQueries(3): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_no_worker_execution_right(self): + """ + A user can delete a worker run with no right on its worker but an admin access to the process project + """ + self.worker_1.memberships.update(level=Role.Guest.value) + self.client.force_login(self.user) + + with self.assertNumQueries(6): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + with self.assertRaises(WorkerRun.DoesNotExist): + self.run_1.refresh_from_db() + + def test_delete_local(self): + """ + A user cannot delete a worker run on a local process + """ + run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) + self.client.force_login(self.user) + + with self.assertNumQueries(4): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), ["WorkerRuns can only be deleted from Workers or Dataset processes."]) + + def test_delete(self): + self.client.force_login(self.user) + + with self.assertNumQueries(6): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + with self.assertRaises(WorkerRun.DoesNotExist): + self.run_1.refresh_from_db() + + def test_delete_with_children(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + version_3 = WorkerVersion.objects.create( + worker=self.worker_1, + version=3, + configuration={"test": "test3"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[self.run_1.id], + ) + run_3 = self.process_1.worker_runs.create( + version=version_3, + parents=[self.run_1.id, run_2.id], + ) + + self.assertTrue(self.run_1.id in run_2.parents) + self.assertTrue(self.run_1.id in run_3.parents) + self.client.force_login(self.user) + + with self.assertNumQueries(6): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + with self.assertRaises(WorkerRun.DoesNotExist): + self.run_1.refresh_from_db() + run_2.refresh_from_db() + run_3.refresh_from_db() + self.assertEqual(run_2.parents, []) + self.assertEqual(run_3.parents, [run_2.id]) + + def test_delete_started_process(self): + """ + A user shouldn't be able to delete the worker run of a started process + """ + self.client.force_login(self.user) + self.process_1.run() + self.process_1.tasks.update(state=State.Running) + + with self.assertNumQueries(3): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), ["WorkerRuns cannot be deleted from a process that has already started."]) + + 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) + + # Agent auth is not implemented in CE + self.client.force_authenticate(user=self.agent) + with self.assertNumQueries(1): + response = self.client.delete( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), { + "detail": "You do not have an admin access to this process.", + }) diff --git a/arkindex/process/tests/worker_runs/test_list.py b/arkindex/process/tests/worker_runs/test_list.py new file mode 100644 index 0000000000000000000000000000000000000000..63123a908d33f5c725d11be5f6fd3994356b6b81 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_list.py @@ -0,0 +1,108 @@ +from django.urls import reverse +from rest_framework import status + +from arkindex.ponos.models import Farm +from arkindex.process.models import FeatureUsage, ProcessMode, WorkerVersion +from arkindex.project.tests import FixtureAPITestCase + + +class TestWorkerRunsList(FixtureAPITestCase): + """ + Test worker runs list endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) + + def test_list_requires_login(self): + with self.assertNumQueries(0): + response = self.client.get(reverse("api:worker-run-list", kwargs={"pk": str(self.process_1.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_no_execution_right(self): + """ + Worker runs attached to a process can be listed even if the user has no execution rights to workers + This is due to the fact a user can see a process running on a corpus they have access + """ + self.worker_1.memberships.all().delete() + self.client.force_login(self.user) + + with self.assertNumQueries(6): + 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) + + self.assertEqual(response.json()["count"], 1) + + def test_list(self): + self.client.force_login(self.user) + + with self.assertNumQueries(6): + 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) + + data = response.json() + self.assertEqual(data["results"], [{ + "id": str(self.run_1.id), + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }]) + + def test_list_filter_process(self): + run_2 = self.process_2.worker_runs.create( + version=self.version_1, + parents=[], + ) + self.client.force_login(self.user) + + with self.assertNumQueries(6): + 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) + + data = response.json() + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["id"], str(run_2.id)) diff --git a/arkindex/process/tests/worker_runs/test_partial_update.py b/arkindex/process/tests/worker_runs/test_partial_update.py new file mode 100644 index 0000000000000000000000000000000000000000..43b484a6a5634da378cbe41d20f369784875b316 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_partial_update.py @@ -0,0 +1,1058 @@ +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.ponos.models import Agent, Farm +from arkindex.process.models import FeatureUsage, ProcessMode, WorkerRun, WorkerVersion, WorkerVersionState +from arkindex.project.tests import FixtureAPITestCase +from arkindex.training.models import Model, ModelVersion, ModelVersionState +from arkindex.users.models import Role + + +class TestWorkerRunsPartialUpdate(FixtureAPITestCase): + """ + Test worker runs partial update endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + cls.version_2 = WorkerVersion.objects.get(worker__slug="dla") + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + cls.configuration_1 = cls.worker_1.configurations.create(name="My config", configuration={"key": "value"}) + worker_version = WorkerVersion.objects.exclude(worker=cls.version_1.worker).first() + cls.configuration_2 = worker_version.worker.configurations.create(name="Config") + cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) + cls.version_gpu_required = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "ikari shinji"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Required, + version=3 + ) + cls.version_gpu_supported = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "ayanami rei"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Supported, + version=4 + ) + cls.version_gpu_disabled = WorkerVersion.objects.create( + worker=cls.worker_1, + configuration={"pilot": "soryu asuka langley"}, + state=WorkerVersionState.Available, + model_usage=FeatureUsage.Disabled, + docker_image_iid="registry.nerv.co.jp/neon-genesis:evangelion", + gpu_usage=FeatureUsage.Disabled, + version=5 + ) + + # Model and Model version setup + cls.model_1 = Model.objects.create(name="My model") + cls.model_1.memberships.create(user=cls.user, level=Role.Contributor.value) + cls.model_version_1 = ModelVersion.objects.create( + model=cls.model_1, + state=ModelVersionState.Available, + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.model_2 = Model.objects.create(name="Their model") + cls.model_2.memberships.create(user=cls.user, level=Role.Guest.value) + cls.model_version_2 = cls.model_2.versions.create( + state=ModelVersionState.Available, + tag="blah", + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.model_3 = Model.objects.create(name="Our model", public=True) + cls.model_version_3 = cls.model_3.versions.create( + state=ModelVersionState.Available, + tag="blah", + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.agent = Agent.objects.create( + farm=cls.farm, + hostname="claude", + cpu_cores=42, + cpu_frequency=1e15, + ram_total=99e9, + last_ping=datetime.now(timezone.utc), + ) + # Add custom attributes to make the agent usable as an authenticated user + cls.agent.is_agent = True + cls.agent.is_anonymous = False + + def test_partial_update_requires_login(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + + with self.assertNumQueries(0): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @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 they have no admin access on its process project + """ + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}) + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + 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): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + + self.client.force_login(self.user) + with self.assertNumQueries(3): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_partial_update_local(self): + """ + A user cannot update a worker run on a local process + """ + run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "parents": [] + } + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], + }) + + def test_partial_update_nonexistent_parent(self): + self.client.force_login(self.user) + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": ["12341234-1234-1234-1234-123412341234"], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), [ + f"Can't add or update WorkerRun {self.run_1.id} because parents field isn't properly defined. It can be either because" + " one or several UUIDs don't refer to existing WorkerRuns or either because listed WorkerRuns doesn't belong to the" + " same Process than this WorkerRun." + ]) + + def test_partial_update_process_id(self): + """ + Process field cannot be updated + """ + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "process_id": str(self.process_2.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + self.run_1.refresh_from_db() + self.assertEqual(self.run_1.process.id, self.process_1.id) + + def test_partial_update_worker_version_id(self): + """ + Version field cannot be updated + """ + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "worker_version_id": str(self.version_2.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + self.run_1.refresh_from_db() + self.assertNotEqual(self.run_1.version_id, self.version_2.id) + + def test_partial_update_configuration(self): + self.client.force_login(self.user) + self.assertEqual(self.run_1.configuration, None) + self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1") + + 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_1.id) + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.run_1.refresh_from_db() + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": { + "id": str(self.configuration_1.id), + "archived": False, + "configuration": {"key": "value"}, + "name": "My config" + }, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1 using configuration 'My config'", + }) + self.assertEqual(self.run_1.configuration.id, self.configuration_1.id) + self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1 using configuration 'My config'") + + def test_partial_update_invalid_configuration(self): + self.client.force_login(self.user) + self.assertEqual(self.run_1.configuration, None) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), + data={"configuration_id": str(self.configuration_2.id)}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) + + def test_partial_update_process_already_started(self): + """ + Update dependencies of a worker run is not possible once the process is started + """ + self.process_1.run() + self.assertTrue(self.process_1.tasks.exists()) + 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)}), + data={ + "parents": [], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], + }) + + def test_partial_update_model_version_not_allowed(self): + """ + The model_version UUID is not allowed when the related version doesn't allow model_usage + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Disabled + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This worker version does not support models."] + }) + + def test_partial_update_unknown_model_version(self): + """ + Cannot use a model version id that doesn't exist + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + random_model_version_uuid = str(uuid.uuid4()) + + with self.assertNumQueries(4): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": random_model_version_uuid, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] + }) + + @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 + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + + # Create a model version, the user has no access to + 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(3): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(model_version_no_access.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": [f'Invalid pk "{model_version_no_access.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), + ]) + + @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 + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + 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), + (self.model_version_2, None, ModelVersionState.Error), + (self.model_version_2, None, ModelVersionState.Available), + (self.model_version_2, "blah", ModelVersionState.Created), + (self.model_version_2, "blah", ModelVersionState.Error), + # On a public model with no membership + (self.model_version_3, None, ModelVersionState.Created), + (self.model_version_3, None, ModelVersionState.Error), + (self.model_version_3, None, ModelVersionState.Available), + (self.model_version_3, "blah", ModelVersionState.Created), + (self.model_version_3, "blah", ModelVersionState.Error), + ] + + 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(4): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(model_version.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "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) + version = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create( + version=version, + parents=[], + ) + self.model_version_1.state = ModelVersionState.Error + self.model_version_1.save() + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This ModelVersion is not in an Available state."], + }) + + def test_partial_update_model_archived(self): + self.client.force_login(self.user) + version = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create(version=version) + self.model_1.archived = datetime.now(timezone.utc) + self.model_1.save() + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This ModelVersion is part of an archived model."], + }) + + def test_partial_update_model_version(self): + """ + Update the worker run by adding a model_version with a worker version that supports it + """ + self.client.force_login(self.user) + version_with_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create( + version=version_with_model, + parents=[], + ) + self.assertIsNone(run.model_version_id) + self.assertEqual(run.summary, "Worker Recognizer @ version 2") + + model_versions = [ + # Version on a model with contributor access + self.model_version_1, + # Available version with tag on a model with guest access + self.model_version_2, + # Available version with tag on a public model + self.model_version_3, + ] + for model_version in model_versions: + with self.subTest(model_version=model_version): + with self.assertNumQueries(6): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(model_version.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + run.refresh_from_db() + self.assertEqual(response.json(), { + "id": str(run.id), + "configuration": None, + "model_version": { + "id": str(model_version.id), + "configuration": {}, + "model": { + "id": str(model_version.model.id), + "name": model_version.model.name + }, + "size": 8, + "state": "available", + "tag": model_version.tag, + }, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(version_with_model.id), + "configuration": {"test": "test2"}, + "docker_image_iid": None, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Required.value, + "revision_url": None, + "version": version_with_model.version, + "tag": None, + "created": version_with_model.created.isoformat().replace("+00:00", "Z"), + "state": "created", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}", + }) + self.assertEqual(run.model_version_id, model_version.id) + self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}") + + def test_partial_update_model_version_with_configuration(self): + """ + Updating the worker run by adding a model_version with a worker version + doesn't erase previously loaded worker configuration + """ + self.client.force_login(self.user) + version_with_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create( + version=version_with_model, + parents=[], + configuration=self.configuration_1 + ) + self.assertEqual(run.model_version_id, None) + + with self.assertNumQueries(6): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + run.refresh_from_db() + self.assertEqual(response.json(), { + "id": str(run.id), + "configuration": { + "archived": False, + "configuration": {"key": "value"}, + "id": str(self.configuration_1.id), + "name": "My config" + }, + "model_version": { + "id": str(self.model_version_1.id), + "configuration": {}, + "model": { + "id": str(self.model_1.id), + "name": "My model" + }, + "size": 8, + "state": "available", + "tag": None + }, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(version_with_model.id), + "configuration": {"test": "test2"}, + "docker_image_iid": None, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Required.value, + "revision_url": None, + "version": version_with_model.version, + "tag": None, + "created": version_with_model.created.isoformat().replace("+00:00", "Z"), + "state": "created", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'", + }) + self.assertEqual(run.model_version_id, self.model_version_1.id) + self.assertEqual(run.configuration_id, self.configuration_1.id) + + def test_partial_update(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + self.client.force_login(self.user) + + with self.assertNumQueries(7): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.run_1.refresh_from_db() + self.assertDictEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [str(run_2.id)], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + def test_partial_update_unique(self): + self.client.force_login(self.user) + self.version_1.model_usage = FeatureUsage.Required + self.version_1.save() + cases = [ + (None, None), + (None, self.configuration_1), + (self.model_version_1, None), + (self.model_version_1, self.configuration_1), + ] + for model_version, configuration in cases: + with self.subTest(model_version=model_version, configuration=configuration): + # Erase any previous failures + self.process_1.worker_runs.exclude(id=self.run_1.id).delete() + + self.run_1.model_version = model_version + self.run_1.configuration = configuration + self.run_1.save() + + # Ensure the other run has different values before updating to avoid conflicts + run_2 = self.process_1.worker_runs.create( + version=self.version_1, + model_version=None if model_version else self.model_version_1, + configuration=None if configuration else self.configuration_1, + ) + + # Having a model version or a configuration adds one query for each + query_count = 4 + bool(model_version) + bool(configuration) + + with self.assertNumQueries(query_count): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + # Update the second worker run to the first worker run's values to cause a conflict + "model_version_id": str(model_version.id) if model_version else None, + "configuration_id": str(configuration.id) if configuration else None, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], + }) + + 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) + + # Agent auth is not implemented in CE + self.client.force_authenticate(user=self.agent) + with self.assertNumQueries(1): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), { + "detail": "You do not have an admin access to this process.", + }) + + def test_update_use_gpu_errors(self): + self.client.force_login(self.user) + + cases = [ + (self.version_gpu_disabled, True, "This worker version does not support GPU usage."), + (self.version_gpu_required, False, "This worker version requires GPU usage.") + ] + + for worker_version, use_gpu, error_message in cases: + with self.subTest(worker_version=worker_version, use_gpu=use_gpu, error_message=error_message): + run = WorkerRun.objects.create( + process=self.process_1, + version=worker_version, + parents=[] + ) + self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) + with self.assertNumQueries(3): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={"use_gpu": use_gpu} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual(response.json(), {"use_gpu": [error_message]}) + + run.refresh_from_db() + self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) + + def test_update_use_gpu(self): + self.client.force_login(self.user) + + cases = [ + (self.version_gpu_disabled, False), + (self.version_gpu_required, True), + (self.version_gpu_supported, False), + (self.version_gpu_supported, True) + ] + + for worker_version, use_gpu in cases: + with self.subTest(worker_version=worker_version, use_gpu=use_gpu): + self.process_1.worker_runs.all().delete() + run = WorkerRun.objects.create( + process=self.process_1, + version=worker_version, + parents=[] + ) + self.assertEqual(run.use_gpu, True if worker_version.gpu_usage == FeatureUsage.Required else False) + + with self.assertNumQueries(5): + response = self.client.patch( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={"use_gpu": use_gpu} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json() + self.assertDictEqual(data, { + "id": str(run.id), + "worker_version": { + "id": str(worker_version.id), + "configuration": worker_version.configuration, + "docker_image_iid": "registry.nerv.co.jp/neon-genesis:evangelion", + "gpu_usage": worker_version.gpu_usage.value, + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": worker_version.version, + "tag": None, + "created": worker_version.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + }, + "summary": f"Worker Recognizer @ version {worker_version.version}", + "use_gpu": use_gpu + }) + run.refresh_from_db() + self.assertEqual(run.use_gpu, use_gpu) diff --git a/arkindex/process/tests/worker_runs/test_retrieve.py b/arkindex/process/tests/worker_runs/test_retrieve.py new file mode 100644 index 0000000000000000000000000000000000000000..7c2471ee10aa682c89da96dbb4e429af0d564047 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_retrieve.py @@ -0,0 +1,405 @@ +from datetime import datetime, timezone + +from django.urls import reverse +from rest_framework import status + +from arkindex.ponos.models import Agent, Farm +from arkindex.process.models import ( + FeatureUsage, + ProcessMode, + Worker, + WorkerRun, + WorkerVersion, +) +from arkindex.project.tests import FixtureAPITestCase +from arkindex.users.models import Role + + +class TestWorkerRunsretrieve(FixtureAPITestCase): + """ + Test worker runs retrieve endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + + cls.worker_custom = Worker.objects.get(slug="custom") + cls.version_custom = cls.worker_custom.versions.get() + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + cls.run_custom = cls.local_process.worker_runs.get(version=cls.version_custom) + cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) + + cls.agent = Agent.objects.create( + farm=cls.farm, + hostname="claude", + cpu_cores=42, + cpu_frequency=1e15, + ram_total=99e9, + last_ping=datetime.now(timezone.utc), + ) + # Add custom attributes to make the agent usable as an authenticated user + cls.agent.is_agent = True + cls.agent.is_anonymous = False + + def test_retrieve_requires_login(self): + with self.assertNumQueries(0): + response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)})) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_no_worker_execution_right(self): + """ + A user can retrieve any worker run if they have an admin access to the process project + """ + self.worker_1.memberships.update(level=Role.Guest.value) + self.client.force_login(self.user) + + with self.assertNumQueries(4): + 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) + + self.assertDictEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + def test_retrieve_invalid_id(self): + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.get( + reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}) + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve(self): + self.client.force_login(self.user) + + with self.assertNumQueries(4): + 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) + + self.assertDictEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + def test_retrieve_custom(self): + self.client.force_login(self.user) + # Update process attributes to ensure they do not cause extra requests + page = self.corpus.elements.get(name="Volume 1, page 1r") + self.local_process.element = page + self.local_process.element_type = page.type + self.local_process.folder_type = page.type + self.local_process.prefix = "a" + self.local_process.save() + with self.assertNumQueries(5): + response = self.client.get(reverse("api:worker-run-details", kwargs={"pk": str(self.run_custom.id)})) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertDictEqual(response.json(), { + "id": str(self.run_custom.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.local_process.id), + "activity_state": "disabled", + "corpus": None, + "chunks": 1, + "mode": "local", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": { + "id": str(page.id), + "type": "page", + "corpus": { + "id": str(self.corpus.id), + "name": "Unit Tests", + "public": True + }, + "name": "Volume 1, page 1r", + "rotation_angle": 0, + "mirrored": False, + "thumbnail_url": None, + "zone": { + "id": str(page.id), + "image": { + "id": str(page.image.id), + "height": 1000, + "path": "img1", + "s3_url": None, + "server": { + "display_name": "Test Server", + "max_height": None, + "max_width": None, + "url": "http://server" + }, + "status": "unchecked", + "url": "http://server/img1", + "width": 1000 + }, + "polygon": [[int(x), int(y)] for x, y in page.polygon], + "url": "http://server/img1/0,0,1000,1000/full/0/default.jpg", + }, + }, + "element_type": "page", + "folder_type": "page", + "prefix": "a", + }, + "worker_version": { + "id": str(self.version_custom.id), + "configuration": {"custom": "value"}, + "docker_image_iid": None, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "created", + "worker": { + "id": str(self.worker_custom.id), + "name": "Custom worker", + "slug": "custom", + "type": "custom", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "summary": "Worker Custom worker @ version 1", + "use_gpu": False, + }) + + def test_retrieve_local(self): + """ + A user can retrieve a run on their own local process + """ + run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.get( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertDictEqual(response.json(), { + "id": str(run.id), + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.local_process.id), + "activity_state": "disabled", + "corpus": None, + "chunks": 1, + "mode": "local", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + def test_retrieve_local_only_current_user(self): + """ + A user cannot retrieve a run on another user's local process + """ + run = WorkerRun.objects.filter(process__creator=self.superuser, process__mode=ProcessMode.Local).first() + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.get( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_agent(self): + """ + A Ponos agent can retrieve a WorkerRun on a process where it has some assigned tasks + """ + self.process_1.tasks.create(run=0, depth=0, slug="something", agent=self.agent) + + # Agent auth is not implemented in CE + self.client.force_authenticate(user=self.agent) + with self.assertNumQueries(3): + 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) + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "parents": [], + "model_version": None, + "configuration": None, + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "mode": "workers", + "chunks": 1, + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + 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) + + # Agent auth is not implemented in CE + self.client.force_authenticate(user=self.agent) + with self.assertNumQueries(1): + response = self.client.get( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/arkindex/process/tests/worker_runs/test_update.py b/arkindex/process/tests/worker_runs/test_update.py new file mode 100644 index 0000000000000000000000000000000000000000..b4e38afa695846dd92b96d5dd732c26dbbbb9a04 --- /dev/null +++ b/arkindex/process/tests/worker_runs/test_update.py @@ -0,0 +1,999 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import call, patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.exceptions import ValidationError + +from arkindex.ponos.models import Agent, Farm +from arkindex.process.models import FeatureUsage, ProcessMode, WorkerVersion +from arkindex.project.tests import FixtureAPITestCase +from arkindex.training.models import Model, ModelVersion, ModelVersionState +from arkindex.users.models import Role + + +class TestWorkerRunsUpdate(FixtureAPITestCase): + """ + Test worker runs update endpoint + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.local_process = cls.user.processes.get(mode=ProcessMode.Local) + cls.farm = Farm.objects.first() + cls.process_1 = cls.corpus.processes.create( + creator=cls.user, + mode=ProcessMode.Workers, + farm=cls.farm, + ) + cls.version_1 = WorkerVersion.objects.get(worker__slug="reco") + cls.worker_1 = cls.version_1.worker + cls.version_2 = WorkerVersion.objects.get(worker__slug="dla") + cls.run_1 = cls.process_1.worker_runs.create(version=cls.version_1, parents=[]) + cls.configuration_1 = cls.worker_1.configurations.create(name="My config", configuration={"key": "value"}) + worker_version = WorkerVersion.objects.exclude(worker=cls.version_1.worker).first() + cls.configuration_2 = worker_version.worker.configurations.create(name="Config") + cls.process_2 = cls.corpus.processes.create(creator=cls.user, mode=ProcessMode.Workers) + + # Model and Model version setup + cls.model_1 = Model.objects.create(name="My model") + cls.model_1.memberships.create(user=cls.user, level=Role.Contributor.value) + cls.model_version_1 = ModelVersion.objects.create( + model=cls.model_1, + state=ModelVersionState.Available, + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.model_2 = Model.objects.create(name="Their model") + cls.model_2.memberships.create(user=cls.user, level=Role.Guest.value) + cls.model_version_2 = cls.model_2.versions.create( + state=ModelVersionState.Available, + tag="blah", + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.model_3 = Model.objects.create(name="Our model", public=True) + cls.model_version_3 = cls.model_3.versions.create( + state=ModelVersionState.Available, + tag="blah", + size=8, + hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + archive_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + + cls.agent = Agent.objects.create( + farm=cls.farm, + hostname="claude", + cpu_cores=42, + cpu_frequency=1e15, + ram_total=99e9, + last_ping=datetime.now(timezone.utc), + ) + # Add custom attributes to make the agent usable as an authenticated user + cls.agent.is_agent = True + cls.agent.is_anonymous = False + + def test_update_requires_nothing(self): + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_requires_login(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + + with self.assertNumQueries(0): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @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 they have no admin access on its process project + """ + self.corpus.memberships.filter(user=self.user).update(level=Role.Contributor.value) + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [] + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + 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 + """ + run = self.local_process.worker_runs.create(version=self.version_1, parents=[]) + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "parents": [] + } + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns can only be created or updated on Workers or Dataset processes."], + }) + + def test_update_invalid_id(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + self.client.force_login(self.user) + + with self.assertNumQueries(3): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": "12341234-1234-1234-1234-123412341234"}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_nonexistent_parent(self): + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": ["12341234-1234-1234-1234-123412341234"], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), [ + f"Can't add or update WorkerRun {self.run_1.id} because parents field isn't properly defined. It can be either because" + " one or several UUIDs don't refer to existing WorkerRuns or either because listed WorkerRuns doesn't belong to the" + " same Process than this WorkerRun." + ]) + + def test_update_duplicate_parents(self): + self.client.force_login(self.user) + run_2 = self.process_1.worker_runs.create(version=self.version_2) + + with self.assertNumQueries(4): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [ + str(run_2.id), + str(run_2.id), + ], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "parents": ["The parents of a WorkerRun must be unique."], + }) + + def test_duplicate_parents_signal(self): + """ + Duplicates in WorkerRun.parents are also detected outside of the WorkerRun APIs + """ + run_2 = self.process_1.worker_runs.create( + version=self.version_2, + parents=[], + ) + + run_2.parents = [self.run_1.id, self.run_1.id] + with self.assertRaisesRegex(ValidationError, f"Can't add or update WorkerRun {run_2.id} because it has duplicate parents."): + run_2.parents = [self.run_1.id, self.run_1.id] + run_2.save() + + def test_update_process_id(self): + """ + Process field cannot be updated + """ + self.client.force_login(self.user) + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "process_id": str(self.process_2.id), + "parents": [], + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + self.run_1.refresh_from_db() + self.assertEqual(self.run_1.process.id, self.process_1.id) + + def test_update_worker_version_id(self): + """ + Version field cannot be updated + """ + self.client.force_login(self.user) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "worker_version_id": str(self.version_2.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + }, + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + self.run_1.refresh_from_db() + self.assertNotEqual(self.run_1.version_id, self.version_2.id) + + def test_update_configuration(self): + self.client.force_login(self.user) + self.assertEqual(self.run_1.configuration, None) + # Check generated summary, before updating, it should not be that verbose + self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1") + + 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_1.id) + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.run_1.refresh_from_db() + + self.assertEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": { + "id": str(self.configuration_1.id), + "archived": False, + "configuration": {"key": "value"}, + "name": "My config" + }, + "model_version": None, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1 using configuration 'My config'", + }) + self.assertEqual(self.run_1.configuration.id, self.configuration_1.id) + # Check generated summary, after the update, the configuration should be displayed as well + self.assertEqual(self.run_1.summary, "Worker Recognizer @ version 1 using configuration 'My config'") + + def test_update_invalid_configuration(self): + self.client.force_login(self.user) + self.assertEqual(self.run_1.configuration, None) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": self.run_1.id}), + data={"parents": [], "configuration_id": str(self.configuration_2.id)}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertDictEqual(response.json(), {"configuration_id": ["The configuration must be part of the same worker."]}) + + def test_update_process_already_started(self): + """ + Update dependencies of a worker run is not possible once the process is started + """ + self.process_1.run() + self.assertTrue(self.process_1.tasks.exists()) + 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)}), + data={ + "parents": [], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "process_id": ["WorkerRuns cannot be added or updated on processes that have already started."], + }) + + def test_update_model_version_not_allowed(self): + """ + The model_version UUID is not allowed when the related version doesn't allow model_usage + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Disabled + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This worker version does not support models."] + }) + + def test_update_unknown_model_version(self): + """ + Cannot use a model version id that doesn't exist + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + random_model_version_uuid = str(uuid.uuid4()) + + with self.assertNumQueries(4): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": random_model_version_uuid, + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": [f'Invalid pk "{random_model_version_uuid}" - object does not exist.'] + }) + + @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 + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + parents=[], + ) + + # Create a model version, the user has no access to + 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(3): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(model_version_no_access.id), + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": [f'Invalid pk "{model_version_no_access.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), + ]) + + @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 + """ + self.client.force_login(self.user) + version_no_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run_2 = self.process_1.worker_runs.create( + version=version_no_model, + 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), + (self.model_version_2, None, ModelVersionState.Error), + (self.model_version_2, None, ModelVersionState.Available), + (self.model_version_2, "blah", ModelVersionState.Created), + (self.model_version_2, "blah", ModelVersionState.Error), + # On a public model with no membership + (self.model_version_3, None, ModelVersionState.Created), + (self.model_version_3, None, ModelVersionState.Error), + (self.model_version_3, None, ModelVersionState.Available), + (self.model_version_3, "blah", ModelVersionState.Created), + (self.model_version_3, "blah", ModelVersionState.Error), + ] + + 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(4): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + "model_version_id": str(model_version.id), + "parents": [], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "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) + version = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create( + version=version, + parents=[], + ) + self.model_version_1.state = ModelVersionState.Error + self.model_version_1.save() + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This ModelVersion is not in an Available state."] + }) + + def test_update_model_archived(self): + self.client.force_login(self.user) + version = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create(version=version) + self.model_1.archived = datetime.now(timezone.utc) + self.model_1.save() + + with self.assertNumQueries(5): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "model_version_id": ["This ModelVersion is part of an archived model."], + }) + + def test_update_model_version_id(self): + """ + Update the worker run by adding a model_version with a worker version that supports it + """ + self.client.force_login(self.user) + version_with_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Supported + ) + run = self.process_1.worker_runs.create( + version=version_with_model, + parents=[], + ) + self.assertEqual(run.model_version, None) + # Check generated summary, before updating, there should be only information about the worker version + self.assertEqual(run.summary, "Worker Recognizer @ version 2") + + model_versions = [ + # Version on a model with contributor access + self.model_version_1, + # Available version with tag on a model with guest access + self.model_version_2, + # Available version with tag on a public model + self.model_version_3, + ] + for model_version in model_versions: + with self.subTest(model_version=model_version): + with self.assertNumQueries(6): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(model_version.id), + "parents": [], + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + run.refresh_from_db() + self.assertEqual(response.json(), { + "id": str(run.id), + "configuration": None, + "model_version": { + "id": str(model_version.id), + "configuration": {}, + "model": { + "id": str(model_version.model.id), + "name": model_version.model.name + }, + "size": 8, + "state": "available", + "tag": model_version.tag, + }, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(version_with_model.id), + "configuration": {"test": "test2"}, + "docker_image_iid": None, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Supported.value, + "revision_url": None, + "version": version_with_model.version, + "tag": None, + "created": version_with_model.created.isoformat().replace("+00:00", "Z"), + "state": "created", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}", + }) + self.assertEqual(run.model_version_id, model_version.id) + self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {model_version.model.name} @ {str(model_version.id)[:6]}") + + def test_update_configuration_and_model_version(self): + """ + Update the worker run by adding both a model_version and a worker configuration + """ + self.client.force_login(self.user) + version_with_model = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"}, + model_usage=FeatureUsage.Required + ) + run = self.process_1.worker_runs.create( + version=version_with_model, + parents=[], + ) + self.assertIsNone(run.model_version) + self.assertIsNone(run.configuration) + # Check generated summary, before updating, there should be only information about the worker version + self.assertEqual(run.summary, "Worker Recognizer @ version 2") + + with self.assertNumQueries(7): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run.id)}), + data={ + "model_version_id": str(self.model_version_1.id), + "configuration_id": str(self.configuration_1.id), + "parents": [] + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + run.refresh_from_db() + self.assertEqual(response.json(), { + "id": str(run.id), + "configuration": { + "id": str(self.configuration_1.id), + "archived": False, + "configuration": {"key": "value"}, + "name": "My config" + }, + "model_version": { + "id": str(self.model_version_1.id), + "configuration": {}, + "model": { + "id": str(self.model_1.id), + "name": "My model" + }, + "size": 8, + "state": "available", + "tag": None + }, + "parents": [], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(version_with_model.id), + "configuration": {"test": "test2"}, + "docker_image_iid": None, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Required.value, + "revision_url": None, + "version": version_with_model.version, + "tag": None, + "created": version_with_model.created.isoformat().replace("+00:00", "Z"), + "state": "created", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": f"Worker Recognizer @ version 2 with model My model @ {str(self.model_version_1.id)[:6]} using configuration 'My config'", + }) + self.assertEqual(run.model_version_id, self.model_version_1.id) + # Check generated summary, after updating, there should be information about the model loaded + self.assertEqual(run.summary, f"Worker Recognizer @ version 2 with model {self.model_version_1.model.name} @ {str(self.model_version_1.id)[:6]} using configuration '{self.configuration_1.name}'") + + def test_update(self): + version_2 = WorkerVersion.objects.create( + worker=self.worker_1, + version=2, + configuration={"test": "test2"} + ) + run_2 = self.process_1.worker_runs.create( + version=version_2, + parents=[], + ) + self.client.force_login(self.user) + + with self.assertNumQueries(7): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + data={ + "parents": [str(run_2.id)], + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.run_1.refresh_from_db() + self.assertDictEqual(response.json(), { + "id": str(self.run_1.id), + "configuration": None, + "model_version": None, + "parents": [str(run_2.id)], + "process": { + "id": str(self.process_1.id), + "activity_state": "disabled", + "corpus": str(self.corpus.id), + "chunks": 1, + "mode": "workers", + "name": None, + "state": "unscheduled", + "use_cache": False, + "element": None, + "element_type": None, + "folder_type": None, + "prefix": None, + }, + "worker_version": { + "id": str(self.version_1.id), + "configuration": {"test": 42}, + "docker_image_iid": self.version_1.docker_image_iid, + "gpu_usage": "disabled", + "model_usage": FeatureUsage.Disabled.value, + "revision_url": None, + "version": 1, + "tag": None, + "created": self.version_1.created.isoformat().replace("+00:00", "Z"), + "state": "available", + "worker": { + "id": str(self.worker_1.id), + "name": "Recognizer", + "slug": "reco", + "type": "recognizer", + "description": "", + "repository_url": None, + "archived": False, + } + }, + "use_gpu": False, + "summary": "Worker Recognizer @ version 1", + }) + + def test_update_unique(self): + self.client.force_login(self.user) + self.version_1.model_usage = FeatureUsage.Required + self.version_1.save() + cases = [ + (None, None), + (None, self.configuration_1), + (self.model_version_1, None), + (self.model_version_1, self.configuration_1), + ] + for model_version, configuration in cases: + with self.subTest(model_version=model_version, configuration=configuration): + # Erase any previous failures + self.process_1.worker_runs.exclude(id=self.run_1.id).delete() + + self.run_1.model_version = model_version + self.run_1.configuration = configuration + self.run_1.save() + + # Ensure the other run has different values before updating to avoid conflicts + run_2 = self.process_1.worker_runs.create( + version=self.version_1, + model_version=None if model_version else self.model_version_1, + configuration=None if configuration else self.configuration_1, + ) + + # Having a model version or a configuration adds one query for each + query_count = 4 + bool(model_version) + bool(configuration) + + with self.assertNumQueries(query_count): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(run_2.id)}), + data={ + # Update the second worker run to the first worker run's values to cause a conflict + "model_version_id": str(model_version.id) if model_version else None, + "configuration_id": str(configuration.id) if configuration else None, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertEqual(response.json(), { + "__all__": ["A WorkerRun already exists on this process with the selected worker version, model version and configuration."], + }) + + 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) + + # Agent auth is not implemented in CE + self.client.force_authenticate(user=self.agent) + with self.assertNumQueries(1): + response = self.client.put( + reverse("api:worker-run-details", kwargs={"pk": str(self.run_1.id)}), + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json(), { + "detail": "You do not have an admin access to this process.", + })