Repository: saleor/saleor

Issue:
Missing AttributeValueTranslation.name for richText attributes
For reich text attributes, Saleor sets `AttributeValue.name` to a text summary of the `richText` field. We should implement the same behavior for `AttributeValueTranslation.name`.

In addition, the existing `AttributeValueTranslation` values should be updated through a migration. 


Discussion:

Resolving patch:
diff --git a/saleor/attribute/migrations/0022_attribute_value_translations_propagate_names.py b/saleor/attribute/migrations/0022_attribute_value_translations_propagate_names.py
new file mode 100644
--- /dev/null
+++ b/saleor/attribute/migrations/0022_attribute_value_translations_propagate_names.py
@@ -0,0 +1,149 @@
+# Generated by Django 3.2.16 on 2022-10-17 10:10
+
+import re
+import warnings
+from typing import Dict, Optional
+
+from django.db import migrations
+from django.template.defaultfilters import truncatechars
+from django.utils.html import strip_tags
+from urllib3.util import parse_url
+
+# Start copy of /saleor/core/utils/editorjs.py
+BLACKLISTED_URL_SCHEMES = ("javascript",)
+HYPERLINK_TAG_WITH_URL_PATTERN = r"(.*?<a\s+href=\\?\")(\w+://\S+[^\\])(\\?\">)"
+
+
+def clean_editor_js(definitions: Optional[Dict], *, to_string: bool = False):
+    """Sanitize a given EditorJS JSON definitions.
+
+    Look for not allowed URLs, replaced them with `invalid` value, and clean valid ones.
+
+    `to_string` flag is used for returning concatenated string from all blocks
+     instead of returning json object.
+    """
+    if definitions is None:
+        return "" if to_string else definitions
+
+    blocks = definitions.get("blocks")
+
+    if not blocks or not isinstance(blocks, list):
+        return "" if to_string else definitions
+
+    plain_text_list = []
+
+    for index, block in enumerate(blocks):
+        block_type = block["type"]
+        data = block.get("data")
+        if not data or not isinstance(data, dict):
+            continue
+
+        if block_type == "list":
+            for item_index, item in enumerate(block["data"]["items"]):
+                if not item:
+                    continue
+                new_text = clean_text_data(item)
+                if to_string:
+                    plain_text_list.append(strip_tags(new_text))
+                else:
+                    blocks[index]["data"]["items"][item_index] = new_text
+        else:
+            text = block["data"].get("text")
+            if not text:
+                continue
+            new_text = clean_text_data(text)
+            if to_string:
+                plain_text_list.append(strip_tags(new_text))
+            else:
+                blocks[index]["data"]["text"] = new_text
+
+    return " ".join(plain_text_list) if to_string else definitions
+
+
+def clean_text_data(text: str):
+    """Look for url in text, check if URL is allowed and return the cleaned URL.
+
+    By default, only the protocol ``javascript`` is denied.
+    """
+
+    if not text:
+        return
+
+    end_of_match = 0
+    new_text = ""
+    for match in re.finditer(HYPERLINK_TAG_WITH_URL_PATTERN, text):
+        original_url = match.group(2)
+        original_url.strip()
+
+        url = parse_url(original_url)
+        new_url = url.url
+        if url.scheme in BLACKLISTED_URL_SCHEMES:
+            warnings.warn(
+                f"An invalid url was sent: {original_url} "
+                f"-- Scheme: {url.scheme} is blacklisted"
+            )
+            new_url = "#invalid"
+
+        new_text += match.group(1) + new_url + match.group(3)
+        end_of_match = match.end()
+
+    if end_of_match:
+        new_text += text[end_of_match:]
+
+    return new_text if new_text else text
+
+
+# End copy of /saleor/core/utils/editorjs.py
+
+
+def queryset_in_batches(queryset):
+    """Slice a queryset into batches.
+
+    Input queryset should be sorted be pk.
+    """
+    start_pk = 0
+
+    while True:
+        qs = queryset.filter(pk__gt=start_pk)[:2000]
+        pks = list(qs.values_list("pk", flat=True))
+
+        if not pks:
+            break
+
+        yield pks
+
+        start_pk = pks[-1]
+
+
+def propagate_names_for_rich_text_attribute_value_translations(apps, schema_editor):
+    AttributeValueTranslation = apps.get_model("attribute", "AttributeValueTranslation")
+
+    queryset = (
+        AttributeValueTranslation.objects.exclude(rich_text=None)
+        .filter(name="")
+        .order_by("pk")
+    )
+
+    for batch_pks in queryset_in_batches(queryset):
+        batch = AttributeValueTranslation.objects.filter(pk__in=batch_pks)
+        instances = []
+        for instance in batch:
+            instance.name = truncatechars(
+                clean_editor_js(instance.rich_text, to_string=True), 100
+            )
+            instances.append(instance)
+        AttributeValueTranslation.objects.bulk_update(instances, ["name"])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("attribute", "0021_auto_20220406_1713"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            propagate_names_for_rich_text_attribute_value_translations,
+            migrations.RunPython.noop,
+        ),
+    ]
diff --git a/saleor/graphql/translations/mutations.py b/saleor/graphql/translations/mutations.py
--- a/saleor/graphql/translations/mutations.py
+++ b/saleor/graphql/translations/mutations.py
@@ -2,11 +2,14 @@
 from django.core.exceptions import ValidationError
 from django.db import transaction
 from django.db.models import Model
+from django.template.defaultfilters import truncatechars
 from graphql import GraphQLError
 
+from ...attribute import AttributeInputType
 from ...attribute import models as attribute_models
 from ...core.permissions import SitePermissions
 from ...core.tracing import traced_atomic_transaction
+from ...core.utils.editorjs import clean_editor_js
 from ...discount import models as discount_models
 from ...menu import models as menu_models
 from ...page import models as page_models
@@ -110,12 +113,18 @@ def clean_node_id(cls, **data):
     def validate_input(cls, input_data):
         validate_input_against_model(cls._meta.model, input_data)
 
+    @classmethod
+    def pre_update_or_create(cls, instance, input_data):
+        return input_data
+
     @classmethod
     def perform_mutation(cls, _root, info, **data):
         node_id, model_type = cls.clean_node_id(**data)
         instance = cls.get_node_or_error(info, node_id, only_type=model_type)
         cls.validate_input(data["input"])
 
+        data["input"] = cls.pre_update_or_create(instance, data["input"])
+
         translation, created = instance.translations.update_or_create(
             language_code=data["language_code"], defaults=data["input"]
         )
@@ -326,6 +335,15 @@ class Meta:
         error_type_field = "translation_errors"
         permissions = (SitePermissions.MANAGE_TRANSLATIONS,)
 
+    @classmethod
+    def pre_update_or_create(cls, instance, input_data):
+        if instance.attribute.input_type == AttributeInputType.RICH_TEXT:
+            if "name" not in input_data.keys() or input_data["name"] is None:
+                input_data["name"] = truncatechars(
+                    clean_editor_js(input_data["rich_text"], to_string=True), 100
+                )
+        return input_data
+
 
 class SaleTranslate(BaseTranslateMutation):
     class Arguments:


Test patch:
diff --git a/saleor/graphql/translations/tests/test_translations.py b/saleor/graphql/translations/tests/test_translations.py
--- a/saleor/graphql/translations/tests/test_translations.py
+++ b/saleor/graphql/translations/tests/test_translations.py
@@ -1909,15 +1909,19 @@ def test_attribute_update_translation(
 
 
 ATTRIBUTE_VALUE_TRANSLATE_MUTATION = """
-    mutation attributeValueTranslate($attributeValueId: ID!, $name: String) {
+    mutation attributeValueTranslate(
+        $attributeValueId: ID!,
+        $input: AttributeValueTranslationInput!
+    ) {
         attributeValueTranslate(
             id: $attributeValueId,
             languageCode: PL,
-            input: { name: $name }
+            input: $input
         ) {
             attributeValue {
                 translation(languageCode: PL) {
                     name
+                    richText
                     language {
                         code
                     }
@@ -1948,7 +1952,7 @@ def test_attribute_value_create_translation(
     )
     response = staff_api_client.post_graphql(
         ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
-        {"attributeValueId": attribute_value_id, "name": "Róż PL"},
+        {"attributeValueId": attribute_value_id, "input": {"name": "Róż PL"}},
         permissions=[permission_manage_translations],
     )
     data = get_graphql_content(response)["data"]["attributeValueTranslate"]
@@ -1963,6 +1967,37 @@ def test_attribute_value_create_translation(
     )
 
 
+def test_rich_text_attribute_value_create_translation(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_base_text = "Nowy Opis"
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert data["attributeValue"]["translation"]["name"] == expected_base_text
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
 def test_attribute_value_create_translation_by_translatable_content_id(
     staff_api_client, pink_attribute_value, permission_manage_translations
 ):
@@ -1971,7 +2006,7 @@ def test_attribute_value_create_translation_by_translatable_content_id(
     )
     response = staff_api_client.post_graphql(
         ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
-        {"attributeValueId": translatable_content_id, "name": "Róż PL"},
+        {"attributeValueId": translatable_content_id, "input": {"name": "Róż PL"}},
         permissions=[permission_manage_translations],
     )
     data = get_graphql_content(response)["data"]["attributeValueTranslate"]
@@ -2003,7 +2038,7 @@ def test_attribute_value_update_translation(
     )
     response = staff_api_client.post_graphql(
         ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
-        {"attributeValueId": attribute_value_id, "name": "Róż PL"},
+        {"attributeValueId": attribute_value_id, "input": {"name": "Róż PL"}},
         permissions=[permission_manage_translations],
     )
     data = get_graphql_content(response)["data"]["attributeValueTranslate"]
@@ -2018,6 +2053,189 @@ def test_attribute_value_update_translation(
     )
 
 
+def test_rich_text_attribute_value_update_translation_only_rich_text(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    base_text = "Base Text"
+    rich_text = dummy_editorjs(base_text)
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value.translations.create(
+        language_code="pl", name=base_text, rich_text=rich_text
+    )
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_base_text = "Nowy Opis"
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert data["attributeValue"]["translation"]["name"] == expected_base_text
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
+def test_rich_text_attribute_value_update_translation_only_rich_text_long_text(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    base_text = "Base Text"
+    rich_text = dummy_editorjs(base_text)
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value.translations.create(
+        language_code="pl", name=base_text, rich_text=rich_text
+    )
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_base_text = "Nowy Opis. " * 100
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert (
+        data["attributeValue"]["translation"]["name"] == expected_base_text[:99] + "…"
+    )
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
+def test_rich_text_attribute_value_update_translation_only_rich_text_name_set_manually(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    base_text = "Base Text"
+    rich_text = dummy_editorjs(base_text)
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value.translations.create(
+        language_code="pl", name=base_text, rich_text=rich_text
+    )
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_name = "Moja nazwa attrybutu"
+    expected_base_text = "Nowy Opis"
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"name": expected_name, "richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert data["attributeValue"]["translation"]["name"] == expected_name
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
+def test_rich_text_attribute_value_update_translation_only_rich_text_empty_name(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    base_text = "Base Text"
+    rich_text = dummy_editorjs(base_text)
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value.translations.create(
+        language_code="pl", name=base_text, rich_text=rich_text
+    )
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_base_text = "Nowy Opis"
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"name": "", "richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert data["attributeValue"]["translation"]["name"] == ""
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
+def test_rich_text_attribute_value_update_translation_only_rich_text_name_null(
+    staff_api_client,
+    product_with_rich_text_attribute,
+    permission_manage_translations,
+):
+    # given
+    base_text = "Base Text"
+    rich_text = dummy_editorjs(base_text)
+    assigned_attribute = product_with_rich_text_attribute[0].attributes.first()
+    attribute_value = assigned_attribute.attribute.values.first()
+    attribute_value.translations.create(
+        language_code="pl", name=base_text, rich_text=rich_text
+    )
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", attribute_value.id
+    )
+    expected_base_text = "Nowy Opis"
+    expected_rich_text = json.dumps(dummy_editorjs(expected_base_text))
+
+    # when
+    response = staff_api_client.post_graphql(
+        ATTRIBUTE_VALUE_TRANSLATE_MUTATION,
+        {
+            "attributeValueId": attribute_value_id,
+            "input": {"name": None, "richText": expected_rich_text},
+        },
+        permissions=[permission_manage_translations],
+    )
+
+    # then
+    data = get_graphql_content(response)["data"]["attributeValueTranslate"]
+    assert data["attributeValue"]["translation"]["language"]["code"] == "PL"
+    assert data["attributeValue"]["translation"]["name"] == expected_base_text
+    assert data["attributeValue"]["translation"]["richText"] == expected_rich_text
+
+
 SHIPPING_PRICE_TRANSLATE = """
     mutation shippingPriceTranslate(
         $shippingMethodId: ID!, $input: ShippingPriceTranslationInput!