diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe86c40bfa..6dd913dfe52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ All notable, unreleased changes to this project will be documented in this file. - Introduce product reference attributes - #6711 by @IKarbowiak - Add metadata to warehouse - #6727 by @d-wysocki - Add page webhooks: `PAGE_CREATED`, `PAGE_UPDATED` and `PAGE_DELETED` - #6787 by @d-wysocki +- Add `PRODUCT_DELETED` webhook - #6794 by @d-wysocki - Fix `product_updated` and `product_created` webhooks - #6798 by @d-wysocki # 2.11.1 diff --git a/saleor/graphql/product/bulk_mutations/products.py b/saleor/graphql/product/bulk_mutations/products.py index 83e094b2287..67d4cd7bd82 100644 --- a/saleor/graphql/product/bulk_mutations/products.py +++ b/saleor/graphql/product/bulk_mutations/products.py @@ -87,21 +87,45 @@ class Meta: @classmethod def perform_mutation(cls, _root, info, ids, **data): _, pks = resolve_global_ids_to_primary_keys(ids, Product) - variants = models.ProductVariant.objects.filter(product__pk__in=pks) + product_to_variant = list( + models.ProductVariant.objects.filter(product__pk__in=pks).values_list( + "product_id", "id" + ) + ) + variants_ids = [variant_id for _, variant_id in product_to_variant] + # get draft order lines for products order_line_pks = list( order_models.OrderLine.objects.filter( - variant__in=variants, order__status=OrderStatus.DRAFT + variant_id__in=variants_ids, order__status=OrderStatus.DRAFT ).values_list("pk", flat=True) ) - - response = super().perform_mutation(_root, info, ids, **data) + response = super().perform_mutation( + _root, + info, + ids, + manager=info.context.plugins, + product_to_variant=product_to_variant, + **data, + ) # delete order lines for deleted variants order_models.OrderLine.objects.filter(pk__in=order_line_pks).delete() return response + @classmethod + def bulk_action(cls, queryset, manager, product_to_variant): + product_variant_map = defaultdict(list) + for product, variant in product_to_variant: + product_variant_map[product].append(variant) + + products = [product for product in queryset] + queryset.delete() + for product in products: + variants = product_variant_map.get(product.id) + manager.product_deleted(product, variants) + class BulkAttributeValueInput(InputObjectType): id = graphene.ID(description="ID of the selected attribute.") diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py index 2e5547c7b47..421b43ece83 100644 --- a/saleor/graphql/product/mutations/products.py +++ b/saleor/graphql/product/mutations/products.py @@ -681,19 +681,19 @@ def success_response(cls, instance): @classmethod def perform_mutation(cls, _root, info, **data): node_id = data.get("id") - instance = cls.get_node_or_error(info, node_id, only_type=Product) + instance = cls.get_node_or_error(info, node_id, only_type=Product) + variants_id = list(instance.variants.all().values_list("id", flat=True)) # get draft order lines for variant line_pks = list( order_models.OrderLine.objects.filter( - variant__in=instance.variants.all(), order__status=OrderStatus.DRAFT + variant_id__in=variants_id, order__status=OrderStatus.DRAFT ).values_list("pk", flat=True) ) - response = super().perform_mutation(_root, info, **data) - # delete order lines for deleted variant order_models.OrderLine.objects.filter(pk__in=line_pks).delete() + info.context.plugins.product_deleted(instance, variants_id) return response diff --git a/saleor/graphql/product/tests/test_bulk_delete.py b/saleor/graphql/product/tests/test_bulk_delete.py index 84fb5536e15..c339e54d4b5 100644 --- a/saleor/graphql/product/tests/test_bulk_delete.py +++ b/saleor/graphql/product/tests/test_bulk_delete.py @@ -249,6 +249,34 @@ def test_delete_products( assert OrderLine.objects.filter(pk__in=not_draft_order_lines_pks).exists() +@patch("saleor.plugins.webhook.plugin.trigger_webhooks_for_event.delay") +def test_delete_products_trigger_webhook( + mocked_webhook_trigger, + staff_api_client, + product_list, + permission_manage_products, + channel_USD, + settings, +): + # given + settings.PLUGINS = ["saleor.plugins.webhook.plugin.WebhookPlugin"] + + query = DELETE_PRODUCTS_MUTATION + variables = { + "ids": [ + graphene.Node.to_global_id("Product", product.id) + for product in product_list + ] + } + response = staff_api_client.post_graphql( + query, variables, permissions=[permission_manage_products] + ) + content = get_graphql_content(response) + + assert content["data"]["productBulkDelete"]["count"] == 3 + assert mocked_webhook_trigger.called + + def test_delete_products_variants_in_draft_order( staff_api_client, product_list, permission_manage_products ): diff --git a/saleor/graphql/product/tests/test_product.py b/saleor/graphql/product/tests/test_product.py index d876c423fb6..ea7c5cbcbea 100644 --- a/saleor/graphql/product/tests/test_product.py +++ b/saleor/graphql/product/tests/test_product.py @@ -38,6 +38,8 @@ from ....product.utils.costs import get_product_costs_data from ....tests.utils import dummy_editorjs from ....warehouse.models import Allocation, Stock, Warehouse +from ....webhook.event_types import WebhookEventType +from ....webhook.payloads import generate_product_deleted_payload from ...core.enums import AttributeErrorCode, ReportingPeriod from ...tests.utils import ( assert_no_permission, @@ -4906,6 +4908,37 @@ def test_delete_product(staff_api_client, product, permission_manage_products): assert node_id == data["product"]["id"] +@patch("saleor.plugins.webhook.plugin.trigger_webhooks_for_event.delay") +def test_delete_product_trigger_webhook( + mocked_webhook_trigger, + staff_api_client, + product, + permission_manage_products, + settings, +): + settings.PLUGINS = ["saleor.plugins.webhook.plugin.WebhookPlugin"] + + query = DELETE_PRODUCT_MUTATION + node_id = graphene.Node.to_global_id("Product", product.id) + variants_id = list(product.variants.all().values_list("id", flat=True)) + variables = {"id": node_id} + response = staff_api_client.post_graphql( + query, variables, permissions=[permission_manage_products] + ) + content = get_graphql_content(response) + data = content["data"]["productDelete"] + assert data["product"]["name"] == product.name + with pytest.raises(product._meta.model.DoesNotExist): + product.refresh_from_db() + assert node_id == data["product"]["id"] + + expected_data = generate_product_deleted_payload(product, variants_id) + + mocked_webhook_trigger.assert_called_once_with( + WebhookEventType.PRODUCT_DELETED, expected_data + ) + + def test_delete_product_variant_in_draft_order( staff_api_client, product_with_two_variants, diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 51d57353fc8..893f6e89720 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -5785,6 +5785,7 @@ enum WebhookEventTypeEnum { CUSTOMER_CREATED PRODUCT_CREATED PRODUCT_UPDATED + PRODUCT_DELETED CHECKOUT_CREATED CHECKOUT_UPDATED FULFILLMENT_CREATED @@ -5806,6 +5807,7 @@ enum WebhookSampleEventTypeEnum { CUSTOMER_CREATED PRODUCT_CREATED PRODUCT_UPDATED + PRODUCT_DELETED CHECKOUT_CREATED CHECKOUT_UPDATED FULFILLMENT_CREATED diff --git a/saleor/plugins/base_plugin.py b/saleor/plugins/base_plugin.py index 8a0c4788a60..62799694bc4 100644 --- a/saleor/plugins/base_plugin.py +++ b/saleor/plugins/base_plugin.py @@ -367,6 +367,16 @@ def product_updated(self, product: "Product", previous_value: Any) -> Any: """ return NotImplemented + def product_deleted( + self, product: "Product", variants: List[int], previous_value: Any + ) -> Any: + """Trigger when product is deleted. + + Overwrite this method if you need to trigger specific logic after a product is + deleted. + """ + return NotImplemented + def order_fully_paid(self, order: "Order", previous_value: Any) -> Any: """Trigger when order is fully paid. diff --git a/saleor/plugins/manager.py b/saleor/plugins/manager.py index 52fd882c6f2..0f6990e58bf 100644 --- a/saleor/plugins/manager.py +++ b/saleor/plugins/manager.py @@ -387,6 +387,12 @@ def product_updated(self, product: "Product"): default_value = None return self.__run_method_on_plugins("product_updated", default_value, product) + def product_deleted(self, product: "Product", variants: List[int]): + default_value = None + return self.__run_method_on_plugins( + "product_deleted", default_value, product, variants + ) + def order_created(self, order: "Order"): default_value = None return self.__run_method_on_plugins("order_created", default_value, order) diff --git a/saleor/plugins/webhook/plugin.py b/saleor/plugins/webhook/plugin.py index 82326094aca..196c42619ed 100644 --- a/saleor/plugins/webhook/plugin.py +++ b/saleor/plugins/webhook/plugin.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, List, Optional from ...webhook.event_types import WebhookEventType from ...webhook.payloads import ( @@ -8,6 +8,7 @@ generate_invoice_payload, generate_order_payload, generate_page_payload, + generate_product_deleted_payload, generate_product_payload, ) from ..base_plugin import BasePlugin @@ -121,6 +122,14 @@ def product_updated(self, product: "Product", previous_value: Any) -> Any: product_data = generate_product_payload(product) trigger_webhooks_for_event.delay(WebhookEventType.PRODUCT_UPDATED, product_data) + def product_deleted( + self, product: "Product", variants: List[int], previous_value: Any + ) -> Any: + if not self.active: + return previous_value + product_data = generate_product_deleted_payload(product, variants) + trigger_webhooks_for_event.delay(WebhookEventType.PRODUCT_DELETED, product_data) + def checkout_created(self, checkout: "Checkout", previous_value: Any) -> Any: if not self.active: return previous_value diff --git a/saleor/plugins/webhook/tests/test_webhook.py b/saleor/plugins/webhook/tests/test_webhook.py index 9ed150cb5f6..19f6416c560 100644 --- a/saleor/plugins/webhook/tests/test_webhook.py +++ b/saleor/plugins/webhook/tests/test_webhook.py @@ -1,5 +1,7 @@ +import json from unittest import mock +import graphene import pytest from ....app.models import App @@ -10,6 +12,7 @@ generate_invoice_payload, generate_order_payload, generate_page_payload, + generate_product_deleted_payload, generate_product_payload, ) from ...manager import get_plugins_manager @@ -148,6 +151,33 @@ def test_product_updated(mocked_webhook_trigger, settings, product): ) +@mock.patch("saleor.plugins.webhook.plugin.trigger_webhooks_for_event.delay") +def test_product_deleted(mocked_webhook_trigger, settings, product): + settings.PLUGINS = ["saleor.plugins.webhook.plugin.WebhookPlugin"] + manager = get_plugins_manager() + + product = product + variants_id = list(product.variants.all().values_list("id", flat=True)) + product_id = product.id + product.delete() + product.id = product_id + variant_global_ids = [ + graphene.Node.to_global_id("ProductVariant", pk) for pk in variants_id + ] + manager.product_deleted(product, variants_id) + + expected_data = generate_product_deleted_payload(product, variants_id) + + expected_data_dict = json.loads(expected_data)[0] + assert expected_data_dict["id"] is not None + assert expected_data_dict["variants"] is not None + assert variant_global_ids == expected_data_dict["variants"] + + mocked_webhook_trigger.assert_called_once_with( + WebhookEventType.PRODUCT_DELETED, expected_data + ) + + @mock.patch("saleor.plugins.webhook.plugin.trigger_webhooks_for_event.delay") def test_order_updated(mocked_webhook_trigger, settings, order_with_lines): settings.PLUGINS = ["saleor.plugins.webhook.plugin.WebhookPlugin"] diff --git a/saleor/webhook/event_types.py b/saleor/webhook/event_types.py index 86e3c1bbf2d..fc1048c92b4 100644 --- a/saleor/webhook/event_types.py +++ b/saleor/webhook/event_types.py @@ -26,6 +26,7 @@ class WebhookEventType: PRODUCT_CREATED = "product_created" PRODUCT_UPDATED = "product_updated" + PRODUCT_DELETED = "product_deleted" CHECKOUT_CREATED = "checkout_created" CHECKOUT_UPADTED = "checkout_updated" @@ -48,6 +49,7 @@ class WebhookEventType: CUSTOMER_CREATED: "Customer created", PRODUCT_CREATED: "Product created", PRODUCT_UPDATED: "Product updated", + PRODUCT_DELETED: "Product deleted", CHECKOUT_CREATED: "Checkout created", CHECKOUT_UPADTED: "Checkout updated", FULFILLMENT_CREATED: "Fulfillment_created", @@ -70,6 +72,7 @@ class WebhookEventType: (CUSTOMER_CREATED, DISPLAY_LABELS[CUSTOMER_CREATED]), (PRODUCT_CREATED, DISPLAY_LABELS[PRODUCT_CREATED]), (PRODUCT_UPDATED, DISPLAY_LABELS[PRODUCT_UPDATED]), + (PRODUCT_DELETED, DISPLAY_LABELS[PRODUCT_DELETED]), (CHECKOUT_CREATED, DISPLAY_LABELS[CHECKOUT_CREATED]), (CHECKOUT_UPADTED, DISPLAY_LABELS[CHECKOUT_UPADTED]), (FULFILLMENT_CREATED, DISPLAY_LABELS[FULFILLMENT_CREATED]), @@ -91,6 +94,7 @@ class WebhookEventType: CUSTOMER_CREATED: AccountPermissions.MANAGE_USERS, PRODUCT_CREATED: ProductPermissions.MANAGE_PRODUCTS, PRODUCT_UPDATED: ProductPermissions.MANAGE_PRODUCTS, + PRODUCT_DELETED: ProductPermissions.MANAGE_PRODUCTS, CHECKOUT_CREATED: CheckoutPermissions.MANAGE_CHECKOUTS, CHECKOUT_UPADTED: CheckoutPermissions.MANAGE_CHECKOUTS, FULFILLMENT_CREATED: OrderPermissions.MANAGE_ORDERS, diff --git a/saleor/webhook/payloads.py b/saleor/webhook/payloads.py index 2c772ad578f..234cac7f2e2 100644 --- a/saleor/webhook/payloads.py +++ b/saleor/webhook/payloads.py @@ -1,6 +1,7 @@ import json from typing import Iterable, Optional +import graphene from django.db.models import QuerySet from ..account.models import User @@ -200,24 +201,25 @@ def generate_customer_payload(customer: "User"): return data +PRODUCT_FIELDS = ( + "name", + "description", + "currency", + "attributes", + "updated_at", + "charge_taxes", + "weight", + "publication_date", + "is_published", + "private_metadata", + "metadata", +) + + def generate_product_payload(product: "Product"): serializer = PayloadSerializer( extra_model_fields={"ProductVariant": ("quantity", "quantity_allocated")} ) - - product_fields = ( - "name", - "description", - "currency", - "attributes", - "updated_at", - "charge_taxes", - "weight", - "publication_date", - "is_published", - "private_metadata", - "metadata", - ) product_variant_fields = ( "sku", "name", @@ -230,7 +232,7 @@ def generate_product_payload(product: "Product"): ) product_payload = serializer.serialize( [product], - fields=product_fields, + fields=PRODUCT_FIELDS, additional_fields={ "category": (lambda p: p.category, ("name", "slug")), "collections": (lambda p: p.collections.all(), ("name", "slug")), @@ -243,6 +245,20 @@ def generate_product_payload(product: "Product"): return product_payload +def generate_product_deleted_payload(product: "Product", variants_id): + serializer = PayloadSerializer() + product_fields = PRODUCT_FIELDS + variant_global_ids = [ + graphene.Node.to_global_id("ProductVariant", pk) for pk in variants_id + ] + product_payload = serializer.serialize( + [product], + fields=product_fields, + extra_dict_data={"variants": list(variant_global_ids)}, + ) + return product_payload + + def generate_fulfillment_lines_payload(fulfillment: Fulfillment): serializer = PayloadSerializer() lines = FulfillmentLine.objects.prefetch_related(