Repository: saleor/saleor
Pull request: Added metadata for channels
I want to merge this change because it will support metadata for channels
it resolves #13228 

<!-- 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 either absent or guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migrations are either absent or optimized for zero downtime
* [ ] The changes are covered by test cases


Problem statement:
Add metadata support to channels
Extend the `Channel` type with `ObjectWithMetadata` interface to add support for metadata in channels. See other types that use this interface for reference implementation.

Use case:
Useful for storing channel level configuration, for apps, logistics, languages, localization etc.

Hints from issue discussion:

Commit message: Added metadata to ChannelInput and updated the changelog

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0931c88c735..f4cfab11959 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Deprecate `Order.trackingClientId` field - #13146 by @SzymJ
 - Fix error "Cannot return null for non-nullable field Webhook.name" - #12989 by @Smit-Parmar
 - Added `GiftCardFilterInput.createdByEmail` filter - #13132 by @Smit-Parmar
+- Added `metadata` and `privateMetadata` in `ChannelInput`. #13230 by @Smit-Parmar
 
 ### Saleor Apps
 
diff --git a/saleor/channel/migrations/0013_auto_20230629_0814.py b/saleor/channel/migrations/0013_auto_20230629_0814.py
new file mode 100644
index 00000000000..6faf700a1c9
--- /dev/null
+++ b/saleor/channel/migrations/0013_auto_20230629_0814.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.2.19 on 2023-06-29 08:14
+
+from django.db import migrations, models
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("channel", "0012_channel_delete_expired_orders_after"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="channel",
+            name="metadata",
+            field=models.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="channel",
+            name="private_metadata",
+            field=models.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/saleor/channel/models.py b/saleor/channel/models.py
index 30f8a3b56bb..99d1c8b9e4a 100644
--- a/saleor/channel/models.py
+++ b/saleor/channel/models.py
@@ -4,11 +4,12 @@
 from django.db import models
 from django_countries.fields import CountryField
 
+from ..core.models import ModelWithMetadata
 from ..permission.enums import ChannelPermissions
 from . import AllocationStrategy, MarkAsPaidStrategy, TransactionFlowStrategy
 
 
-class Channel(models.Model):
+class Channel(ModelWithMetadata):
     name = models.CharField(max_length=250)
     is_active = models.BooleanField(default=False)
     slug = models.SlugField(max_length=255, unique=True)
diff --git a/saleor/graphql/channel/mutations/channel_create.py b/saleor/graphql/channel/mutations/channel_create.py
index 65b6046be9d..bcb8cebc7a6 100644
--- a/saleor/graphql/channel/mutations/channel_create.py
+++ b/saleor/graphql/channel/mutations/channel_create.py
@@ -15,6 +15,7 @@
     ADDED_IN_312,
     ADDED_IN_313,
     ADDED_IN_314,
+    ADDED_IN_315,
     PREVIEW_FEATURE,
 )
 from ...core.doc_category import (
@@ -25,7 +26,9 @@
 from ...core.mutations import ModelMutation
 from ...core.scalars import Day, Minute
 from ...core.types import BaseInputObjectType, ChannelError, NonNullList
+from ...core.types import common as common_types
 from ...core.utils import WebhookEventInfo
+from ...meta.inputs import MetadataInput
 from ...plugins.dataloaders import get_plugin_manager_promise
 from ..enums import (
     AllocationStrategyEnum,
@@ -125,6 +128,16 @@ class ChannelInput(BaseInputObjectType):
         description="The channel order settings" + ADDED_IN_312,
         required=False,
     )
+    metadata = common_types.NonNullList(
+        MetadataInput,
+        description="Channel public metadata." + ADDED_IN_315,
+        required=False,
+    )
+    private_metadata = common_types.NonNullList(
+        MetadataInput,
+        description=("Channel private metadata." + ADDED_IN_315),
+        required=False,
+    )
 
     class Meta:
         doc_category = DOC_CATEGORY_CHANNELS
@@ -168,6 +181,8 @@ class Meta:
                 description="A channel was created.",
             ),
         ]
+        support_meta_field = True
+        support_private_meta_field = True
 
     @classmethod
     def get_type_for_model(cls):
diff --git a/saleor/graphql/channel/mutations/channel_update.py b/saleor/graphql/channel/mutations/channel_update.py
index ee222948374..ead90427f6f 100644
--- a/saleor/graphql/channel/mutations/channel_update.py
+++ b/saleor/graphql/channel/mutations/channel_update.py
@@ -76,6 +76,8 @@ class Meta:
                 description="A channel was updated.",
             ),
         ]
+        support_meta_field = True
+        support_private_meta_field = True
 
     @classmethod
     def clean_input(cls, info: ResolveInfo, instance, data, **kwargs):
diff --git a/saleor/graphql/channel/types.py b/saleor/graphql/channel/types.py
index 7ca17253a7d..b6df36ba66d 100644
--- a/saleor/graphql/channel/types.py
+++ b/saleor/graphql/channel/types.py
@@ -324,7 +324,7 @@ class Channel(ModelObjectType):
     class Meta:
         description = "Represents channel."
         model = models.Channel
-        interfaces = [graphene.relay.Node]
+        interfaces = [graphene.relay.Node, ObjectWithMetadata]
 
     @staticmethod
     def resolve_has_orders(root: models.Channel, info: ResolveInfo):
diff --git a/saleor/graphql/meta/permissions.py b/saleor/graphql/meta/permissions.py
index ffb11bc83c4..50a457f4253 100644
--- a/saleor/graphql/meta/permissions.py
+++ b/saleor/graphql/meta/permissions.py
@@ -14,6 +14,7 @@
     AccountPermissions,
     AppPermission,
     BasePermissionEnum,
+    ChannelPermissions,
     CheckoutPermissions,
     DiscountPermissions,
     GiftcardPermissions,
@@ -195,6 +196,12 @@ def private_app_permssions(
     return [AppPermission.MANAGE_APPS]
 
 
+def channel_permissions(
+    _info: ResolveInfo, _object_pk: Any
+) -> List[BasePermissionEnum]:
+    return [ChannelPermissions.MANAGE_CHANNELS]
+
+
 def checkout_permissions(
     _info: ResolveInfo, _object_pk: Any
 ) -> List[BasePermissionEnum]:
@@ -272,6 +279,7 @@ def tax_permissions(_info: ResolveInfo, _object_pk: int) -> List[BasePermissionE
     "App": app_permissions,
     "Attribute": attribute_permissions,
     "Category": product_permissions,
+    "Channel": channel_permissions,
     "Checkout": no_permissions,
     "CheckoutLine": no_permissions,
     "Collection": product_permissions,
@@ -309,6 +317,7 @@ def tax_permissions(_info: ResolveInfo, _object_pk: int) -> List[BasePermissionE
     "App": private_app_permssions,
     "Attribute": attribute_permissions,
     "Category": product_permissions,
+    "Channel": channel_permissions,
     "Checkout": checkout_permissions,
     "CheckoutLine": checkout_permissions,
     "Collection": product_permissions,
diff --git a/saleor/graphql/meta/resolvers.py b/saleor/graphql/meta/resolvers.py
index e32d5168cf1..2a264b3d42e 100644
--- a/saleor/graphql/meta/resolvers.py
+++ b/saleor/graphql/meta/resolvers.py
@@ -4,6 +4,7 @@
 from ...account import models as account_models
 from ...app import models as app_models
 from ...attribute import models as attribute_models
+from ...channel import models as channel_models
 from ...checkout import models as checkout_models
 from ...core.exceptions import PermissionDenied
 from ...core.models import ModelWithMetadata
@@ -30,6 +31,7 @@ def resolve_object_with_metadata_type(instance):
     from ..account import types as account_types
     from ..app import types as app_types
     from ..attribute import types as attribute_types
+    from ..channel import types as channel_types
     from ..checkout import types as checkout_types
     from ..discount import types as discount_types
     from ..giftcard import types as giftcard_types
@@ -49,6 +51,7 @@ def resolve_object_with_metadata_type(instance):
             account_models.User: account_types.User,
             app_models.App: app_types.App,
             attribute_models.Attribute: attribute_types.Attribute,
+            channel_models.Channel: channel_types.Channel,
             checkout_models.Checkout: checkout_types.Checkout,
             checkout_models.CheckoutMetadata: checkout_types.Checkout,
             checkout_models.CheckoutLine: checkout_types.CheckoutLine,
diff --git a/saleor/graphql/meta/tests/mutations/test_channel.py b/saleor/graphql/meta/tests/mutations/test_channel.py
new file mode 100644
index 00000000000..ab6213ac9eb
--- /dev/null
+++ b/saleor/graphql/meta/tests/mutations/test_channel.py
@@ -0,0 +1,91 @@
+import graphene
+
+from . import PRIVATE_KEY, PRIVATE_VALUE, PUBLIC_KEY, PUBLIC_VALUE
+from .test_delete_metadata import (
+    execute_clear_public_metadata_for_item,
+    item_without_public_metadata,
+)
+from .test_delete_private_metadata import (
+    execute_clear_private_metadata_for_item,
+    item_without_private_metadata,
+)
+from .test_update_metadata import (
+    execute_update_public_metadata_for_item,
+    item_contains_proper_public_metadata,
+)
+from .test_update_private_metadata import (
+    execute_update_private_metadata_for_item,
+    item_contains_proper_private_metadata,
+)
+
+
+def test_delete_private_metadata_for_channel(
+    staff_api_client, permission_manage_channels, channel_USD
+):
+    # given
+    channel_USD.store_value_in_private_metadata({PRIVATE_KEY: PRIVATE_VALUE})
+    channel_USD.save(update_fields=["private_metadata"])
+    channel_id = graphene.Node.to_global_id("Channel", channel_USD.pk)
+
+    # when
+    response = execute_clear_private_metadata_for_item(
+        staff_api_client, permission_manage_channels, channel_id, "Channel"
+    )
+
+    # then
+    assert item_without_private_metadata(
+        response["data"]["deletePrivateMetadata"]["item"], channel_USD, channel_id
+    )
+
+
+def test_delete_public_metadata_for_gift_card(
+    staff_api_client, permission_manage_channels, channel_USD
+):
+    # given
+    channel_USD.store_value_in_metadata({PUBLIC_KEY: PUBLIC_VALUE})
+    channel_USD.save(update_fields=["metadata"])
+    channel_id = graphene.Node.to_global_id("Channel", channel_USD.pk)
+
+    # when
+    response = execute_clear_public_metadata_for_item(
+        staff_api_client, permission_manage_channels, channel_id, "Channel"
+    )
+
+    # then
+    assert item_without_public_metadata(
+        response["data"]["deleteMetadata"]["item"], channel_USD, channel_id
+    )
+
+
+def test_add_public_metadata_for_channel_USD(
+    staff_api_client, permission_manage_channels, channel_USD
+):
+    # given
+    channel_id = graphene.Node.to_global_id("Channel", channel_USD.pk)
+
+    # when
+    response = execute_update_public_metadata_for_item(
+        staff_api_client, permission_manage_channels, channel_id, "Channel"
+    )
+
+    # then
+    assert item_contains_proper_public_metadata(
+        response["data"]["updateMetadata"]["item"], channel_USD, channel_id
+    )
+
+
+def test_add_private_metadata_for_channel_USD(
+    staff_api_client, permission_manage_channels, channel_USD
+):
+    # given
+    channel_id = graphene.Node.to_global_id("Channel", channel_USD.pk)
+
+    # when
+    response = execute_update_private_metadata_for_item(
+        staff_api_client, permission_manage_channels, channel_id, "Channel"
+    )
+
+    # then
+    assert item_contains_proper_private_metadata(
+        response["data"]["updatePrivateMetadata"]["item"], channel_USD, channel_id
+    )
diff --git a/saleor/graphql/meta/tests/queries/test_channel.py b/saleor/graphql/meta/tests/queries/test_channel.py
new file mode 100644
index 00000000000..5e8f65eaee0
--- /dev/null
+++ b/saleor/graphql/meta/tests/queries/test_channel.py
@@ -0,0 +1,179 @@
+import graphene
+
+from ....tests.utils import assert_no_permission, get_graphql_content
+from .utils import PRIVATE_KEY, PRIVATE_VALUE, PUBLIC_KEY, PUBLIC_VALUE
+
+QUERY_CHANNEL_PRIVATE_META = """
+    query channelMeta($id: ID!){
+        channel(id: $id){
+            privateMetadata{
+                key
+                value
+            }
+        }
+    }
+"""
+
+
+def test_query_private_meta_for_channel_as_anonymous_user(api_client, channel_USD):
+    # given
+    variables = {
+        "id": graphene.Node.to_global_id("Channel", channel_USD.pk),
+    }
+
+    # when
+    response = api_client.post_graphql(QUERY_CHANNEL_PRIVATE_META, variables)
+
+    # then
+    assert_no_permission(response)
+
+
+def test_query_private_meta_for_channel_as_customer(user_api_client, channel_USD):
+    # given
+    variables = {
+        "id": graphene.Node.to_global_id("Channel", channel_USD.pk),
+    }
+
+    # when
+    response = user_api_client.post_graphql(QUERY_CHANNEL_PRIVATE_META, variables)
+
+    # then
+    assert_no_permission(response)
+
+
+def test_query_private_meta_for_channel_as_staff(
+    staff_api_client, channel_USD, permission_manage_channels
+):
+    # given
+    channel_USD.store_value_in_private_metadata({PRIVATE_KEY: PRIVATE_VALUE})
+    channel_USD.save(update_fields=["private_metadata"])
+    variables = {"id": graphene.Node.to_global_id("Channel", channel_USD.pk)}
+
+    # when
+    response = staff_api_client.post_graphql(
+        QUERY_CHANNEL_PRIVATE_META,
+        variables,
+        [permission_manage_channels],
+        check_no_permissions=False,
+    )
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["privateMetadata"][0]
+    assert metadata["key"] == PRIVATE_KEY
+    assert metadata["value"] == PRIVATE_VALUE
+
+
+def test_query_private_meta_for_channel_as_app(
+    app_api_client, channel_USD, permission_manage_channels
+):
+    # given
+    channel_USD.store_value_in_private_metadata({PRIVATE_KEY: PRIVATE_VALUE})
+    channel_USD.save(update_fields=["private_metadata"])
+    variables = {
+        "id": graphene.Node.to_global_id("Channel", channel_USD.pk),
+    }
+
+    # when
+    response = app_api_client.post_graphql(
+        QUERY_CHANNEL_PRIVATE_META,
+        variables,
+        [permission_manage_channels],
+        check_no_permissions=False,
+    )
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["privateMetadata"][0]
+    assert metadata["key"] == PRIVATE_KEY
+    assert metadata["value"] == PRIVATE_VALUE
+
+
+QUERY_CHANNEL_PUBLIC_META = """
+    query channelMeta($id: ID!){
+        channel(id: $id){
+            metadata{
+                key
+                value
+            }
+        }
+    }
+"""
+
+
+def test_query_public_meta_for_channel_as_anonymous_user(api_client, channel_USD):
+    # given
+    channel_USD.store_value_in_metadata({PUBLIC_KEY: PUBLIC_VALUE})
+    channel_USD.save(update_fields=["metadata"])
+    variables = {"id": graphene.Node.to_global_id("Channel", channel_USD.pk)}
+
+    # when
+    response = api_client.post_graphql(QUERY_CHANNEL_PUBLIC_META, variables)
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["metadata"][0]
+    assert metadata["key"] == PUBLIC_KEY
+    assert metadata["value"] == PUBLIC_VALUE
+
+
+def test_query_public_meta_for_channel_as_customer(user_api_client, channel_USD):
+    # given
+    channel_USD.store_value_in_metadata({PUBLIC_KEY: PUBLIC_VALUE})
+    channel_USD.save(update_fields=["metadata"])
+    variables = {"id": graphene.Node.to_global_id("Channel", channel_USD.pk)}
+
+    # when
+    response = user_api_client.post_graphql(QUERY_CHANNEL_PUBLIC_META, variables)
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["metadata"][0]
+    assert metadata["key"] == PUBLIC_KEY
+    assert metadata["value"] == PUBLIC_VALUE
+
+
+def test_query_public_meta_for_channel_as_staff(
+    staff_api_client, channel_USD, permission_manage_channels
+):
+    # given
+    channel_USD.store_value_in_metadata({PUBLIC_KEY: PUBLIC_VALUE})
+    channel_USD.save(update_fields=["metadata"])
+    variables = {"id": graphene.Node.to_global_id("Channel", channel_USD.pk)}
+
+    # when
+    response = staff_api_client.post_graphql(
+        QUERY_CHANNEL_PUBLIC_META,
+        variables,
+        [permission_manage_channels],
+        check_no_permissions=False,
+    )
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["metadata"][0]
+    assert metadata["key"] == PUBLIC_KEY
+    assert metadata["value"] == PUBLIC_VALUE
+
+
+def test_query_public_meta_for_channel_as_app(
+    app_api_client, channel_USD, permission_manage_channels
+):
+    # given
+    channel_USD.store_value_in_metadata({PUBLIC_KEY: PUBLIC_VALUE})
+    channel_USD.save(update_fields=["metadata"])
+    variables = {"id": graphene.Node.to_global_id("Channel", channel_USD.pk)}
+
+    # when
+    response = app_api_client.post_graphql(
+        QUERY_CHANNEL_PUBLIC_META,
+        variables,
+        [permission_manage_channels],
+        check_no_permissions=False,
+    )
+    content = get_graphql_content(response)
+
+    # then
+    metadata = content["data"]["channel"]["metadata"][0]
+    assert metadata["key"] == PUBLIC_KEY
+    assert metadata["value"] == PUBLIC_VALUE
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index f10fae0b13e..95c094cea27 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -4757,10 +4757,56 @@ type ShippingMethodChannelListing implements Node @doc(category: "Shipping") {
 }
 
 """Represents channel."""
-type Channel implements Node @doc(category: "Channels") {
+type Channel implements Node & ObjectWithMetadata @doc(category: "Channels") {
   """The ID of the channel."""
   id: ID!
 
+  """List of private metadata items. Requires staff permissions to access."""
+  privateMetadata: [MetadataItem!]!
+
+  """
+  A single key from private metadata. Requires staff permissions to access.
+  
+  Tip: Use GraphQL aliases to fetch multiple keys.
+  
+  Added in Saleor 3.3.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point.
+  """
+  privateMetafield(key: String!): String
+
+  """
+  Private metadata. Requires staff permissions to access. Use `keys` to control which fields you want to include. The default is to include everything.
+  
+  Added in Saleor 3.3.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point.
+  """
+  privateMetafields(keys: [String!]): Metadata
+
+  """List of public metadata items. Can be accessed without permissions."""
+  metadata: [MetadataItem!]!
+
+  """
+  A single key from public metadata.
+  
+  Tip: Use GraphQL aliases to fetch multiple keys.
+  
+  Added in Saleor 3.3.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point.
+  """
+  metafield(key: String!): String
+
+  """
+  Public metadata. Use `keys` to control which fields you want to include. The default is to include everything.
+  
+  Added in Saleor 3.3.
+  
+  Note: this API is currently in Feature Preview and can be subject to changes at later point.
+  """
+  metafields(keys: [String!]): Metadata
+
   """Slug of the channel."""
   slug: String!
 
@@ -26080,6 +26126,20 @@ input ChannelCreateInput @doc(category: "Channels") {
   """
   orderSettings: OrderSettingsInput
 
+  """
+  Channel public metadata.
+  
+  Added in Saleor 3.15.
+  """
+  metadata: [MetadataInput!]
+
+  """
+  Channel private metadata.
+  
+  Added in Saleor 3.15.
+  """
+  privateMetadata: [MetadataInput!]
+
   """Name of the channel."""
   name: String!
 
@@ -26197,6 +26257,20 @@ input ChannelUpdateInput @doc(category: "Channels") {
   """
   orderSettings: OrderSettingsInput
 
+  """
+  Channel public metadata.
+  
+  Added in Saleor 3.15.
+  """
+  metadata: [MetadataInput!]
+
+  """
+  Channel private metadata.
+  
+  Added in Saleor 3.15.
+  """
+  privateMetadata: [MetadataInput!]
+
   """Name of the channel."""
   name: String
 


Code review comments:
diff_hunk: @@ -125,6 +128,16 @@ class ChannelInput(BaseInputObjectType):
         description="The channel order settings" + ADDED_IN_312,
         required=False,
     )
+    metadata = common_types.NonNullList(
+        MetadataInput,
+        description="Channel public metadata." + ADDED_IN_315,
+        required=False,
+    )
+    private_metadata = common_types.NonNullList(
+        MetadataInput,
+        description=("Channel private metadata." + ADDED_IN_315),
path: saleor/graphql/channel/mutations/channel_create.py
text: @user1:
```suggestion
        description="Channel private metadata." + ADDED_IN_315,
```