diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index 8b080c08..5a4d03d0 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -325,8 +325,8 @@ class SerializeMixin: class CreateModelMixin(mixins.CreateModelMixin): - def perform_create(self, serializer): - super().perform_create(serializer) + def perform_create(self, serializer, **kwargs): + serializer.save(**kwargs) signal_create.send(self, instance=serializer.instance) class PartialUpdateModelMixin: @@ -337,8 +337,10 @@ class PartialUpdateModelMixin: """ def perform_update(self, serializer): + instance = serializer.instance + data = serializer.to_representation(instance) old_values = { - attr: serializer.to_representation(serializer.instance).get(attr, None) + attr: data[attr] if attr in data else getattr(instance, attr, None) for attr in self.request.data.keys() } diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index c00e4f13..f0300120 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -41,7 +41,6 @@ from rest_framework.response import Response from rest_framework.exceptions import PermissionDenied from django_sendfile import sendfile -from cvat.apps.webhooks.signals import signal_create, signal_update import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import from cvat.apps.engine.cloud_provider import ( @@ -73,7 +72,7 @@ from cvat.apps.engine.serializers import ( from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job from cvat.apps.engine import backup -from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin +from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin, CreateModelMixin from cvat.apps.engine.location import get_location_configuration, StorageType from . import models, task @@ -273,7 +272,7 @@ class ServerViewSet(viewsets.ViewSet): }) ) class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = models.Project.objects.prefetch_related(Prefetch('label_set', @@ -308,9 +307,11 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return queryset def perform_create(self, serializer): - serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) - signal_create.send(self, instance=serializer.instance) + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) @extend_schema( summary='Method returns information of the tasks of the project with the selected id', @@ -714,7 +715,7 @@ class DataChunkGetter: }) ) class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = Task.objects.prefetch_related( @@ -803,32 +804,25 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, def perform_update(self, serializer): instance = serializer.instance - old_values = {} - old_repr = serializer.to_representation(instance) - for attr in self.request.data.keys(): - old_values[attr] = old_repr[attr] if attr in old_repr \ - else getattr(instance, attr, None) + super().perform_update(serializer) - updated_instance = serializer.save() + updated_instance = serializer.instance if instance.project: instance.project.save() if updated_instance.project: updated_instance.project.save() - if getattr(instance, '_prefetched_objects_cache', None): - instance._prefetched_objects_cache = {} - - signal_update.send(self, instance=serializer.instance, old_values=old_values) - def perform_create(self, serializer): - instance = serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) - if instance.project: - db_project = instance.project + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) + if serializer.instance.project: + db_project = serializer.instance.project db_project.save() - assert instance.organization == db_project.organization - signal_create.send(self, instance=serializer.instance) + assert serializer.instance.organization == db_project.organization def perform_destroy(self, instance): task_dirname = instance.get_dirname() @@ -1708,7 +1702,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, }) ) class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Issue.objects.all().order_by('-id') @@ -1739,8 +1733,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return IssueWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, owner=self.request.user) @extend_schema(summary='The action returns all comments of a specific issue', responses=CommentReadSerializer(many=True)) # Duplicate to still get 'list' op. name @@ -1789,7 +1782,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, }) ) class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Comment.objects.all().order_by('-id') @@ -1815,8 +1808,7 @@ class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return CommentWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, owner=self.request.user) @extend_schema(tags=['users']) @extend_schema_view( diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index 4c94c21a..3e4da2f3 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -8,8 +8,7 @@ from rest_framework.permissions import SAFE_METHODS from django.utils.crypto import get_random_string from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view -from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin -from cvat.apps.webhooks.signals import signal_create +from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin, CreateModelMixin from cvat.apps.iam.permissions import ( InvitationPermission, MembershipPermission, OrganizationPermission) @@ -177,8 +176,8 @@ class MembershipViewSet(mixins.RetrieveModelMixin, DestroyModelMixin, class InvitationViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.ListModelMixin, - mixins.CreateModelMixin, mixins.UpdateModelMixin, + CreateModelMixin, DestroyModelMixin, ): queryset = Invitation.objects.all() @@ -208,8 +207,7 @@ class InvitationViewSet(viewsets.GenericViewSet, 'key': get_random_string(length=64), 'organization': self.request.iam_context['organization'] } - serializer.save(**extra_kwargs) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, **extra_kwargs) def perform_update(self, serializer): if 'accepted' in self.request.query_params: diff --git a/cvat/apps/webhooks/models.py b/cvat/apps/webhooks/models.py index c5636575..e8cf6705 100644 --- a/cvat/apps/webhooks/models.py +++ b/cvat/apps/webhooks/models.py @@ -54,7 +54,6 @@ class Webhook(models.Model): created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - # questionable: should we keep webhook if owner has been deleted? owner = models.ForeignKey( User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" ) diff --git a/cvat/apps/webhooks/serializers.py b/cvat/apps/webhooks/serializers.py index 653659e6..d06a5edf 100644 --- a/cvat/apps/webhooks/serializers.py +++ b/cvat/apps/webhooks/serializers.py @@ -97,11 +97,6 @@ class WebhookReadSerializer(serializers.ModelSerializer): class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): events = EventTypesSerializer(write_only=True) - # Q: should be owner_id required or not? - owner_id = serializers.IntegerField( - write_only=True, allow_null=True, required=False - ) - project_id = serializers.IntegerField( write_only=True, allow_null=True, required=False ) @@ -120,11 +115,10 @@ class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): "secret", "is_active", "enable_ssl", - "owner_id", "project_id", "events", ) - write_once_fields = ("type", "owner_id", "project_id") + write_once_fields = ("type", "project_id") validators = [EventTypeValidator()] def create(self, validated_data): diff --git a/tests/docker-compose.minio.yml b/tests/docker-compose.minio.yml index 572c9482..be8093fc 100644 --- a/tests/docker-compose.minio.yml +++ b/tests/docker-compose.minio.yml @@ -2,7 +2,7 @@ version: '3.3' services: minio: - image: quay.io/minio/minio + image: quay.io/minio/minio:RELEASE.2022-09-17T00-09-45Z hostname: minio restart: always command: server /data --console-address ":9001" @@ -25,7 +25,7 @@ services: aliases: - minio mc: - image: minio/mc + image: minio/mc:RELEASE.2022-09-16T09-16-47Z depends_on: - minio environment: diff --git a/tests/python/rest_api/test_webhooks.py b/tests/python/rest_api/test_webhooks.py index 010e0ab7..17d29df0 100644 --- a/tests/python/rest_api/test_webhooks.py +++ b/tests/python/rest_api/test_webhooks.py @@ -305,7 +305,7 @@ class TestPostWebhooks: assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR def test_cannot_create_non_unique_webhook(self): - pytest.skip("Not implemeted yet") + pytest.skip("Not implemented yet") response = post_method("admin2", "webhooks", self.proj_webhook) response = post_method("admin2", "webhooks", self.proj_webhook) @@ -769,9 +769,7 @@ class TestPatchWebhooks: ) def test_cannot_update_with_nonexistent_contenttype(self): - patch_data = { - "content_type": "application/x-www-form-urlencoded", - } + patch_data = {"content_type": "application/x-www-form-urlencoded"} response = patch_method("admin2", f"webhooks/{self.WID}", patch_data) assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/tests/python/rest_api/test_webhooks_sender.py b/tests/python/rest_api/test_webhooks_sender.py index 5236d2ac..562af214 100644 --- a/tests/python/rest_api/test_webhooks_sender.py +++ b/tests/python/rest_api/test_webhooks_sender.py @@ -10,11 +10,11 @@ import pytest from deepdiff import DeepDiff from shared.fixtures.init import CVAT_ROOT_DIR, _run -from shared.utils.config import get_method, patch_method, post_method +from shared.utils.config import delete_method, get_method, patch_method, post_method # Testing webhook functionality: # - webhook_receiver container receive post request and return responses with the same body -# - cvat save response body for each delivery +# - CVAT save response body for each delivery # # So idea of this testing system is quite simple: # 1) trigger some webhook @@ -49,39 +49,50 @@ def webhook_spec(events, project_id=None, webhook_type="organization"): } +def create_webhook(events, webhook_type, project_id=None, org_id=""): + assert (webhook_type == "project" and project_id is not None) or ( + webhook_type == "organization" and org_id + ) + + response = post_method( + "admin1", "webhooks", webhook_spec(events, project_id, webhook_type), org_id=org_id + ) + assert response.status_code == HTTPStatus.CREATED + + return response.json() + + +def get_deliveries(webhook_id): + response = get_method("admin1", f"webhooks/{webhook_id}/deliveries") + assert response.status_code == HTTPStatus.OK + + deliveries = response.json() + last_payload = json.loads(deliveries["results"][0]["response"]) + + return deliveries, last_payload + + @pytest.mark.usefixtures("changedb") class TestWebhookProjectEvents: - def test_webhook_project_update(self): - events = ["update:project"] - patch_data = {"name": "new_project_name"} - - # create project + def test_webhook_update_project_name(self): response = post_method("admin1", "projects", {"name": "project"}) assert response.status_code == HTTPStatus.CREATED project = response.json() - # create webhook - response = post_method( - "admin1", "webhooks", webhook_spec(events, project["id"], webhook_type="project") - ) - assert response.status_code == HTTPStatus.CREATED - webhook = response.json() + events = ["update:project"] + webhook = create_webhook(events, "project", project_id=project["id"]) - # update project + patch_data = {"name": "new_project_name"} response = patch_method("admin1", f"projects/{project['id']}", patch_data) assert response.status_code == HTTPStatus.OK - # get list of deliveries of webhook response = get_method("admin1", f"webhooks/{webhook['id']}/deliveries") assert response.status_code == HTTPStatus.OK - response_data = response.json() + deliveries, payload = get_deliveries(webhook["id"]) - # check that we sent only one webhook - assert response_data["count"] == 1 + assert deliveries["count"] == 1 - # check value of payload that CVAT sent - payload = json.loads(response_data["results"][0]["response"]) assert payload["event"] == events[0] assert payload["sender"]["username"] == "admin1" assert payload["before_update"]["name"] == project["name"] @@ -96,3 +107,651 @@ class TestWebhookProjectEvents: ) == {} ) + + def test_webhook_update_project_labels(self): + response = post_method("admin1", "projects", {"name": "project"}) + assert response.status_code == HTTPStatus.CREATED + project = response.json() + + events = ["update:project"] + webhook = create_webhook(events, "project", project["id"]) + + patch_data = {"labels": [{"name": "label_0", "color": "#aabbcc"}]} + response = patch_method("admin1", f"projects/{project['id']}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + assert payload["event"] == events[0] + assert len(payload["before_update"]["labels"]) == 0 + assert len(payload["project"]["labels"]) == 1 + assert payload["project"]["labels"][0]["name"] == patch_data["labels"][0]["name"] + assert payload["project"]["labels"][0]["color"] == patch_data["labels"][0]["color"] + + def test_webhook_create_and_delete_project(self, organizations): + org_id = list(organizations)[0]["id"] + events = ["create:project", "delete:project"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + response = post_method("admin1", "projects", {"name": "project_name"}, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + project = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"projects/{project['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:project" + assert delete_payload["event"] == "delete:project" + assert ( + DeepDiff( + create_payload["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookIntersection: + # Test case description: + # few webhooks are triggered by the same event + # In this case we need to check that CVAT will sent + # the right number of payloads to the target url + + def test_project_and_organization_webhooks_intersection(self, organizations): + org_id = list(organizations)[0]["id"] + post_data = {"name": "project_name"} + + response = post_method("admin1", "projects", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + events = ["update:project"] + project_id = response.json()["id"] + webhook_id_1 = create_webhook(events, "organization", org_id=org_id)["id"] + webhook_id_2 = create_webhook(events, "project", project_id=project_id, org_id=org_id)["id"] + + patch_data = {"name": "new_project_name"} + response = patch_method("admin1", f"projects/{project_id}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["project"]["name"] == payload_2["project"]["name"] == patch_data["name"] + + assert ( + payload_1["before_update"]["name"] + == payload_2["before_update"]["name"] + == post_data["name"] + ) + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + assert deliveries_1["results"][0]["webhook_id"] == webhook_id_1 + assert deliveries_2["results"][0]["webhook_id"] == webhook_id_2 + + def test_two_project_webhooks_intersection(self): + post_data = {"name": "project_name"} + response = post_method("admin1", "projects", post_data) + assert response.status_code == HTTPStatus.CREATED + + project_id = response.json()["id"] + events_1 = ["create:task", "update:project"] + events_2 = ["create:task", "create:issue"] + webhook_id_1 = create_webhook(events_1, "project", project_id=project_id)["id"] + webhook_id_2 = create_webhook(events_2, "project", project_id=project_id)["id"] + + post_data = {"name": "project_name", "project_id": project_id} + response = post_method("admin1", "tasks", post_data) + assert response.status_code == HTTPStatus.CREATED + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["event"] == payload_2["event"] == "create:task" + assert payload_1["task"]["name"] == payload_2["task"]["name"] == post_data["name"] + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + def test_two_organization_webhook_intersection(self, organizations): + org_id = list(organizations)[0]["id"] + + events_1 = ["create:project", "update:membership"] + events_2 = ["create:project", "update:job"] + + webhook_id_1 = create_webhook(events_1, "organization", org_id=org_id)["id"] + webhook_id_2 = create_webhook(events_2, "organization", org_id=org_id)["id"] + + post_data = {"name": "project_name"} + response = post_method("admin1", "projects", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + project = response.json() + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + assert ( + DeepDiff( + payload_1["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + payload_2["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookTaskEvents: + def test_webhook_update_task_assignee(self, users, tasks): + task_id, project_id = next( + ( + (task["id"], task["project_id"]) + for task in tasks + if task["project_id"] is not None + and task["organization"] is None + and task["assignee"] is not None + ) + ) + + assignee_id = next( + (user["id"] for user in users if user["id"] != tasks[task_id]["assignee"]["id"]) + ) + + webhook_id = create_webhook(["update:task"], "project", project_id=project_id)["id"] + + patch_data = {"assignee_id": assignee_id} + response = patch_method("admin1", f"tasks/{task_id}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id=webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["assignee_id"] == tasks[task_id]["assignee"]["id"] + assert payload["task"]["assignee"]["id"] == assignee_id + + def test_webhook_update_task_label(self, tasks): + task_id, org_id = next( + ( + (task["id"], task["organization"]) + for task in tasks + if task["project_id"] is None and task["organization"] is not None + ) + ) + + webhook_id = create_webhook(["update:task"], "organization", org_id=org_id)["id"] + + patch_data = {"labels": [{"name": "new_label"}]} + response = patch_method("admin1", f"tasks/{task_id}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id=webhook_id) + + assert deliveries["count"] == 1 + assert ( + len(payload["before_update"]["labels"]) + == len(tasks[task_id]["labels"]) + == len(payload["task"]["labels"]) - 1 + ) + + def test_webhook_create_and_delete_task(self, organizations): + org_id = list(organizations)[0]["id"] + events = ["create:task", "delete:task"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + post_data = {"name": "task_name", "labels": [{"name": "label_0"}]} + response = post_method("admin1", "tasks", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + task = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"tasks/{task['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:task" + assert delete_payload["event"] == "delete:task" + assert ( + DeepDiff( + create_payload["task"], + task, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["task"], + task, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookJobEvents: + def test_webhook_update_job_assignee(self, jobs, tasks, users): + job = next( + ( + job + for job in jobs + if job["assignee"] is None and tasks[job["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"assignee": list(users)[0]["id"]} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["assignee"] is None + assert payload["job"]["assignee"]["id"] == patch_data["assignee"] + + def test_webhook_update_job_stage(self, jobs, tasks): + stages = {"annotation", "validation", "acceptance"} + job = next((job for job in jobs if tasks[job["task_id"]]["organization"] is not None)) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"stage": (stages - {job["stage"]}).pop()} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + assert deliveries["count"] == 1 + assert payload["before_update"]["stage"] == job["stage"] + assert payload["job"]["stage"] == patch_data["stage"] + + def test_webhook_update_job_state(self, jobs, tasks): + states = {"new", "in progress", "rejected", "completed"} + job = next( + ( + job + for job in jobs + if tasks[job["task_id"]]["organization"] is not None + and job["state"] == "in progress" + ) + ) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"state": (states - {job["state"]}).pop()} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + assert deliveries["count"] == 1 + assert payload["before_update"]["state"] == job["state"] + assert payload["job"]["state"] == patch_data["state"] + + +@pytest.mark.usefixtures("changedb") +class TestWebhookIssueEvents: + def test_webhook_update_issue_resolved(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"] + + patch_data = {"resolved": not issue["resolved"]} + response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["resolved"] == issue["resolved"] + assert payload["issue"]["resolved"] == patch_data["resolved"] + + def test_webhook_update_issue_position(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"] + + patch_data = {"position": [0, 1, 2, 3]} + response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["position"] == issue["position"] + assert payload["issue"]["position"] == patch_data["position"] + + def test_webhook_create_and_delete_issue(self, organizations, jobs, tasks): + org_id = list(organizations)[0]["id"] + job_id = next( + (job["id"] for job in jobs if tasks[job["task_id"]]["organization"] == org_id) + ) + events = ["create:issue", "delete:issue"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + post_data = {"frame": 0, "position": [0, 1, 2, 3], "job": job_id, "message": "issue_msg"} + response = post_method("admin1", "issues", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + issue = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"issues/{issue['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:issue" + assert delete_payload["event"] == "delete:issue" + assert ( + DeepDiff( + create_payload["issue"], + issue, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["issue"], + issue, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookMembershipEvents: + def test_webhook_update_membership_role(self, memberships): + roles = {"worker", "supervisor", "maintainer"} + + membership = next( + (membership for membership in memberships if membership["role"] != "owner") + ) + org_id = membership["organization"] + + webhook_id = create_webhook(["update:membership"], "organization", org_id=org_id)["id"] + + patch_data = {"role": (roles - {membership["role"]}).pop()} + response = patch_method( + "admin1", f"memberships/{membership['id']}", patch_data, org_id=org_id + ) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["role"] == membership["role"] + assert payload["membership"]["role"] == patch_data["role"] + + def test_webhook_delete_membership(self, memberships): + membership = next( + (membership for membership in memberships if membership["role"] != "owner") + ) + org_id = membership["organization"] + + webhook_id = create_webhook(["delete:membership"], "organization", org_id=org_id)["id"] + + response = delete_method("admin1", f"memberships/{membership['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert ( + DeepDiff( + payload["membership"], + membership, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookOrganizationEvents: + def test_webhook_update_organization_name(self, organizations): + org_id = list(organizations)[0]["id"] + + webhook_id = create_webhook(["update:organization"], "organization", org_id=org_id)["id"] + + patch_data = {"name": "new_org_name"} + patch_method("admin1", f"organizations/{org_id}", patch_data, org_id=org_id) + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["name"] == organizations[org_id]["name"] + assert payload["organization"]["name"] == patch_data["name"] + + +@pytest.mark.usefixtures("changedb") +class TestWebhookCommentEvents: + def test_webhook_update_comment_message(self, comments, issues, jobs, tasks): + org_comments = list( + (comment, tasks[jobs[issues[comment["issue"]]["job"]]["task_id"]]["organization"]) + for comment in comments + ) + + comment, org_id = next( + ((comment, org_id) for comment, org_id in org_comments if org_id is not None) + ) + + webhook_id = create_webhook(["update:comment"], "organization", org_id=org_id)["id"] + + patch_data = {"message": "new comment message"} + response = patch_method("admin1", f"comments/{comment['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["message"] == comment["message"] + + comment.update(patch_data) + assert ( + DeepDiff( + payload["comment"], + comment, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + def test_webhook_create_and_delete_comment(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + events = ["create:comment", "delete:comment"] + webhook_id = create_webhook(events, "organization", org_id=org_id)["id"] + + post_data = {"issue": issue["id"], "message": "new comment message"} + response = post_method("admin1", f"comments", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + create_deliveries, create_payload = get_deliveries(webhook_id) + + comment_id = response.json()["id"] + response = delete_method("admin1", f"comments/{comment_id}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + delete_deliveries, delete_payload = get_deliveries(webhook_id) + + assert create_deliveries["count"] == 1 + assert delete_deliveries["count"] == 2 + + assert create_payload["event"] == "create:comment" + assert delete_payload["event"] == "delete:comment" + + assert ( + create_payload["comment"]["message"] + == delete_payload["comment"]["message"] + == post_data["message"] + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookPing: + def test_ping_webhook(self, projects): + project_id = list(projects)[0]["id"] + + webhook = create_webhook(["create:task"], "project", project_id=project_id) + + response = post_method("admin1", f"webhooks/{webhook['id']}/ping", {}) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + assert ( + DeepDiff( + payload["webhook"], + webhook, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookRedelivery: + def test_webhook_redelivery(self, projects): + project = list(projects)[0] + + webhook_id = create_webhook(["update:project"], "project", project_id=project["id"])["id"] + + patch_data = {"name": "new_project_name"} + response = patch_method("admin1", f"projects/{project['id']}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries_1, payload_1 = get_deliveries(webhook_id) + delivery_id = deliveries_1["results"][0]["id"] + + response = post_method( + "admin1", f"webhooks/{webhook_id}/deliveries/{delivery_id}/redelivery", {} + ) + assert response.status_code == HTTPStatus.OK + + deliveries_2, payload_2 = get_deliveries(webhook_id) + + assert deliveries_1["count"] == 1 + assert deliveries_2["count"] == 2 + + assert deliveries_1["results"][0]["redelivery"] is False + assert deliveries_2["results"][0]["redelivery"] is True + + project.update(patch_data) + assert ( + DeepDiff( + payload_1["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + payload_2["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) diff --git a/tests/python/shared/assets/comments.json b/tests/python/shared/assets/comments.json new file mode 100644 index 00000000..f1f7457e --- /dev/null +++ b/tests/python/shared/assets/comments.json @@ -0,0 +1,91 @@ +{ + "count": 6, + "next": null, + "previous": null, + "results": [ + { + "created_date": "2022-03-16T12:49:29.372000Z", + "id": 6, + "issue": 5, + "message": "Wrong position", + "owner": { + "first_name": "User", + "id": 20, + "last_name": "Sixth", + "url": "http://localhost:8080/api/users/20", + "username": "user6" + }, + "updated_date": "2022-03-16T12:49:29.372000Z" + }, + { + "created_date": "2022-03-16T12:40:00.767000Z", + "id": 5, + "issue": 4, + "message": "Issue with empty frame", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T12:40:00.767000Z" + }, + { + "created_date": "2022-03-16T11:08:18.370000Z", + "id": 4, + "issue": 3, + "message": "Another one issue", + "owner": { + "first_name": "Business", + "id": 11, + "last_name": "Second", + "url": "http://localhost:8080/api/users/11", + "username": "business2" + }, + "updated_date": "2022-03-16T11:08:18.370000Z" + }, + { + "created_date": "2022-03-16T11:07:22.173000Z", + "id": 3, + "issue": 2, + "message": "Something should be here", + "owner": { + "first_name": "Business", + "id": 11, + "last_name": "Second", + "url": "http://localhost:8080/api/users/11", + "username": "business2" + }, + "updated_date": "2022-03-16T11:07:22.173000Z" + }, + { + "created_date": "2022-03-16T11:04:49.821000Z", + "id": 2, + "issue": 1, + "message": "Just to suffer?", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T11:04:49.821000Z" + }, + { + "created_date": "2022-03-16T11:04:39.447000Z", + "id": 1, + "issue": 1, + "message": "Why are we still here?", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T11:04:39.447000Z" + } + ] +} \ No newline at end of file diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index eba496a4..f9bd1a6e 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -36,7 +36,7 @@ "pk": 1, "fields": { "password": "pbkdf2_sha256$260000$DevmxlmLwciP1P6sZs2Qag$U9DFtjTWx96Sk95qY6UXVcvpdQEP2LcoFBftk5D2RKY=", - "last_login": "2022-09-28T12:20:48.633Z", + "last_login": "2022-09-29T08:00:09.733Z", "is_superuser": true, "username": "admin1", "first_name": "Admin", @@ -463,6 +463,14 @@ "expire_date": "2022-03-07T10:37:08.963Z" } }, +{ + "model": "sessions.session", + "pk": "8r8hzr05yskg13ugvyysz5mq67yu0q09", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pCh1AIu3fcMZIYZbNVAUtqV8e7apAvd_vfef6mI2zrFrckSZ1YXZdTpdyNMDyk74DuWW9WplnWZSe-KPmjTY2V5Xg_372DCNn3rkIZEASSjiAUx3RkkEHXOsA_kDLAAos0eemvYosvBu5xC1_NAnEC9P_-NOIo:1odoSn:hXr9ZRCpPPwIFiVyFia14QHX4dwZigtH7yGUNgU2Gfg", + "expire_date": "2022-10-13T08:00:09.738Z" + } +}, { "model": "sessions.session", "pk": "9432vwcpkukpdrme8vipuk9rmt4jv6c8", @@ -5846,18 +5854,18 @@ }, { "model": "webhooks.webhook", - "pk": 5, + "pk": 6, "fields": { - "target_url": "http://example.com", + "target_url": "http://example.com/", "description": "", - "events": "delete:invitation,delete:project,create:project,delete:comment,update:organization,update:issue,update:task,update:comment,update:job,update:project,delete:task,create:comment,delete:issue,delete:membership,create:invitation,create:task,create:issue,update:membership", + "events": "delete:task,create:task,update:issue,update:membership,create:comment,delete:comment,update:job,update:comment,create:project,delete:invitation,create:invitation,update:project,update:task,delete:membership,delete:issue,delete:project,update:organization,create:issue", "type": "organization", "content_type": "application/json", "secret": "", "is_active": true, "enable_ssl": true, - "created_date": "2022-09-28T12:51:06.703Z", - "updated_date": "2022-09-28T12:51:06.703Z", + "created_date": "2022-09-29T08:00:48.440Z", + "updated_date": "2022-09-29T08:00:48.441Z", "owner": [ "admin1" ], @@ -5865,51 +5873,6 @@ "organization": 1 } }, -{ - "model": "webhooks.webhookdelivery", - "pk": 8, - "fields": { - "webhook": 5, - "event": "create:invitation", - "status_code": 200, - "redelivery": false, - "created_date": "2022-09-28T13:11:37.850Z", - "updated_date": "2022-09-28T13:11:38.311Z", - "changed_fields": "", - "request": { - "event": "create:invitation", - "sender": { - "id": 1, - "url": "http://localhost:8080/api/users/1", - "username": "admin1", - "last_name": "First", - "first_name": "Admin" - }, - "invitation": { - "key": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9", - "role": "supervisor", - "user": { - "id": 19, - "url": "http://localhost:8080/api/users/19", - "username": "user5", - "last_name": "Fifth", - "first_name": "User" - }, - "owner": { - "id": 1, - "url": "http://localhost:8080/api/users/1", - "username": "admin1", - "last_name": "First", - "first_name": "Admin" - }, - "created_date": "2022-09-28T13:11:37.839853Z", - "organization": 1 - }, - "webhook_id": 5 - }, - "response": "\n\n
\nThis domain is for use in illustrative examples in documents. You may use this\n domain in literature without prior coordination or asking for permission.
\n \n