Repository: saleor/saleor
Pull request: Fix webhook serializer fields quantity and quantity_available
I want to merge this change because it resolves #6115 

<!-- Please mention all relevant issue numbers. -->

# Impact

* [ ] New migrations
* [ ] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [X] Database queries are optimized and the number of queries is constant
* [ ] Database migration files are up to date
* [X] The changes are tested
* [ ] GraphQL schema and type definitions are up to date
* [ ] Changes are mentioned in the changelog


Problem statement:
PayloadSerializer quantity and quantity_allocated not serialized properly
### What I'm trying to achieve
Generate product webhook payload

### Steps to reproduce the problem
Run `generate_product_payload` function

### What I expected to happen
Payload contain proper `quantity` and `quantity_allocated`

### What is wrong
Fields have been moved from variant so serializer fields needs to be updated.

Hints from issue discussion:

Commit message: Fix webhook payload variant serializer fields quantity and quantity_allocated

Patch to review:
diff --git a/saleor/product/models.py b/saleor/product/models.py
index 95048858dd3..8ee6e11947c 100644
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -6,7 +6,8 @@
 from django.contrib.postgres.aggregates import StringAgg
 from django.db import models
 from django.db.models import JSONField  # type: ignore
-from django.db.models import Case, Count, F, FilteredRelation, Q, Value, When
+from django.db.models import Case, Count, F, FilteredRelation, Q, Sum, Value, When
+from django.db.models.functions import Coalesce
 from django.urls import reverse
 from django.utils.encoding import smart_text
 from django_measurement.models import MeasurementField
@@ -341,6 +342,14 @@ def __repr__(self) -> str:
 
 
 class ProductVariantQueryset(models.QuerySet):
+    def annotate_quantities(self):
+        return self.annotate(
+            quantity=Coalesce(Sum("stocks__quantity"), 0),
+            quantity_allocated=Coalesce(
+                Sum("stocks__allocations__quantity_allocated"), 0
+            ),
+        )
+
     def create(self, **kwargs):
         """Create a product's variant.
 
diff --git a/saleor/webhook/payload_serializers.py b/saleor/webhook/payload_serializers.py
index 29fdb6156c1..612666d8abd 100644
--- a/saleor/webhook/payload_serializers.py
+++ b/saleor/webhook/payload_serializers.py
@@ -8,16 +8,36 @@
 
 
 class PythonSerializer(PythonBaseSerializer):
+    def __init__(self, extra_model_fields=None):
+        """Serialize a QuerySet to basic Python objects.
+
+        Param extra_model_fields can be provided to add fields to serialization process
+        which are normally ignored (fields that doesn't exist on model).
+        extra_model_fields parameter example:
+        {"ModelName": ["annotated_prop_1", "annotated_prop_2"]}
+        """
+        super().__init__()
+        self.extra_model_fields = extra_model_fields
+
     def get_dump_object(self, obj):
         obj_id = graphene.Node.to_global_id(obj._meta.object_name, obj.id)
         data = OrderedDict([("type", str(obj._meta.object_name)), ("id", obj_id)])
         data.update(self._current)
+
+        if obj._meta.object_name in self.extra_model_fields:
+            fields_to_add = self.extra_model_fields[obj._meta.object_name]
+            for field in fields_to_add:
+                value = getattr(obj, field, None)
+                if value is not None:
+                    data.update({field: str(value)})
+
         return data
 
 
 class PayloadSerializer(JSONSerializer):
-    def __init__(self):
+    def __init__(self, extra_model_fields=None):
         super().__init__()
+        self.extra_model_fields = extra_model_fields or {}
         self.additional_fields = {}
         self.extra_dict_data = {}
         self.obj_id_name = "id"
@@ -45,7 +65,7 @@ def get_dump_object(self, obj):
             [("type", str(obj._meta.object_name)), (self.obj_id_name, obj_id)]
         )
         # Evaluate and add the "additional fields"
-        python_serializer = PythonSerializer()
+        python_serializer = PythonSerializer(extra_model_fields=self.extra_model_fields)
         for field_name, (qs, fields) in self.additional_fields.items():
             data_to_serialize = qs(obj)
             if not data_to_serialize:
diff --git a/saleor/webhook/payloads.py b/saleor/webhook/payloads.py
index 3d74759538c..0a94adfee4a 100644
--- a/saleor/webhook/payloads.py
+++ b/saleor/webhook/payloads.py
@@ -183,7 +183,9 @@ def generate_customer_payload(customer: "User"):
 
 
 def generate_product_payload(product: "Product"):
-    serializer = PayloadSerializer()
+    serializer = PayloadSerializer(
+        extra_model_fields={"ProductVariant": ("quantity", "quantity_allocated")}
+    )
 
     product_fields = (
         "name",
@@ -205,8 +207,6 @@ def generate_product_payload(product: "Product"):
         "currency",
         "price_amount",
         "track_inventory",
-        "quantity",
-        "quantity_allocated",
         "cost_price_amount",
         "private_metadata",
         "metadata",
@@ -217,7 +217,10 @@ def generate_product_payload(product: "Product"):
         additional_fields={
             "category": (lambda p: p.category, ("name", "slug")),
             "collections": (lambda p: p.collections.all(), ("name", "slug")),
-            "variants": (lambda p: p.variants.all(), product_variant_fields),
+            "variants": (
+                lambda p: p.variants.annotate_quantities().all(),
+                product_variant_fields,
+            ),
         },
     )
     return product_payload
diff --git a/saleor/webhook/tests/test_webhook_payload_serializers.py b/saleor/webhook/tests/test_webhook_payload_serializers.py
new file mode 100644
index 00000000000..312d46dd881
--- /dev/null
+++ b/saleor/webhook/tests/test_webhook_payload_serializers.py
@@ -0,0 +1,34 @@
+from saleor.webhook.payload_serializers import PythonSerializer
+
+
+def test_python_serializer_extra_model_fields(product_with_single_variant):
+    serializer = PythonSerializer(
+        extra_model_fields={"ProductVariant": ("quantity", "quantity_allocated")}
+    )
+    annotated_variant = (
+        product_with_single_variant.variants.annotate_quantities().first()
+    )
+    serializer._current = {"test_item": "test_value"}
+    result = serializer.get_dump_object(annotated_variant)
+    assert result["type"] == "ProductVariant"
+    assert result["test_item"] == "test_value"
+    assert result["quantity"] == str(annotated_variant.quantity)
+    assert result["quantity_allocated"] == str(annotated_variant.quantity_allocated)
+
+
+def test_python_serializer_extra_model_fields_incorrect_fields(
+    product_with_single_variant,
+):
+    serializer = PythonSerializer(
+        extra_model_fields={
+            "NonExistingModel": ("__dummy",),
+            "ProductVariant": ("__not_on_model",),
+        }
+    )
+    annotated_variant = (
+        product_with_single_variant.variants.annotate_quantities().first()
+    )
+    serializer._current = {"test_item": "test_value"}
+    result = serializer.get_dump_object(annotated_variant)
+    assert result["type"] == "ProductVariant"
+    assert result["test_item"] == "test_value"


Code review comments:
diff_hunk: @@ -8,16 +8,36 @@
 
 
 class PythonSerializer(PythonBaseSerializer):
+    def __init__(self, extra_model_fields=None):
+        """Serialize a QuerySet to basic Python objects.
+
+        Param extra_model_fields can be provided to add fields to serialization process
+        which are normally ignored (fields that doesn't exist on model).
+        extra_model_fields parameter example:
+        {"ModelName": ["annotated_prop_1", "annotated_prop_2"]}
path: saleor/webhook/payload_serializers.py
text: @user1:
Does it only work for annotated fields? What about property etc? If it works not only for annotated fields we could change the name of one field.

@author:
Will also work on property. That uses `getattr` underneath so it may use anything that exists on model in moment of execution. Will change the name.

@user1:
Great! Good work