Repository: saleor/saleor

Issue:
Translate eg. products without manage_products permissions
### What I'm trying to achieve
To translate hidden products, sales and other permission-protected objects.

### Describe a proposed solution
```
translation(kind: PRODUCT, id: "", languageCode: DE) {
  ..on Product {
    id
    name
    description
    ...
    translation(languageCode: $languageCode) {
      id
      name
      description
      ...
    }
  }
}
```


Discussion:

Resolving patch:
diff --git a/saleor/graphql/page/resolvers.py b/saleor/graphql/page/resolvers.py
--- a/saleor/graphql/page/resolvers.py
+++ b/saleor/graphql/page/resolvers.py
@@ -2,29 +2,19 @@
 
 from ...page import models
 from ..utils import filter_by_query_param
-from .types import Page
 
 PAGE_SEARCH_FIELDS = ("content", "slug", "title")
 
 
-def resolve_page(info, page_id=None, slug=None):
-    assert page_id or slug, "No page ID or slug provided."
+def resolve_page(info, global_page_id=None, slug=None):
+    assert global_page_id or slug, "No page ID or slug provided."
     user = info.context.user
 
     if slug is not None:
-        try:
-            page = models.Page.objects.visible_to_user(user).get(slug=slug)
-        except models.Page.DoesNotExist:
-            page = None
+        page = models.Page.objects.visible_to_user(user).filter(slug=slug).first()
     else:
-        page = graphene.Node.get_node_from_global_id(info, page_id, Page)
-        # Resolve to null if page is not published and user has no permission
-        # to manage pages.
-        is_available_to_user = (
-            page and page.is_published or user.has_perm("page.manage_pages")
-        )
-        if not is_available_to_user:
-            page = None
+        _type, page_pk = graphene.Node.from_global_id(global_page_id)
+        page = models.Page.objects.visible_to_user(user).filter(pk=page_pk).first()
     return page
 
 
diff --git a/saleor/graphql/translations/schema.py b/saleor/graphql/translations/schema.py
--- a/saleor/graphql/translations/schema.py
+++ b/saleor/graphql/translations/schema.py
@@ -1,7 +1,20 @@
 import graphene
 
+from ...discount.models import Sale, Voucher
+from ...menu.models import MenuItem
+from ...page.models import Page
+from ...product.models import (
+    Attribute,
+    AttributeValue,
+    Category,
+    Collection,
+    Product,
+    ProductVariant,
+)
+from ...shipping.models import ShippingMethod
 from ..core.connection import CountableConnection
 from ..core.fields import BaseConnectionField
+from ..decorators import permission_required
 from ..discount import types as discount_types
 from ..discount.resolvers import resolve_sales, resolve_vouchers
 from ..menu import types as menu_types
@@ -17,6 +30,7 @@
     resolve_products,
 )
 from ..shipping import types as shipping_types
+from ..translations import types as translation_types
 from .resolvers import resolve_attribute_values, resolve_shipping_methods
 
 
@@ -37,6 +51,25 @@ class Meta:
         )
 
 
+# TODO Consider name of this class, we should replace to TranslatableItem after
+# `translations` query refactor. Issue #4957
+class DefaultTranslationItem(graphene.Union):
+    class Meta:
+        types = (
+            translation_types.ProductTranslatableContent,
+            translation_types.CollectionTranslatableContent,
+            translation_types.CategoryTranslatableContent,
+            translation_types.AttributeTranslatableContent,
+            translation_types.AttributeValueTranslatableContent,
+            translation_types.ProductVariantTranslatableContent,
+            translation_types.PageTranslatableContent,
+            translation_types.ShippingMethodTranslatableContent,
+            translation_types.SaleTranslatableContent,
+            translation_types.VoucherTranslatableContent,
+            translation_types.MenuItemTranslatableContent,
+        )
+
+
 class TranslatableItemConnection(CountableConnection):
     class Meta:
         node = TranslatableItem
@@ -44,19 +77,20 @@ class Meta:
 
 class TranslatableKinds(graphene.Enum):
     ATTRIBUTE = "Attribute"
-    ATTRIBUTE_VALUE = "Attribute value"
+    ATTRIBUTE_VALUE = "AttributeValue"
     CATEGORY = "Category"
     COLLECTION = "Collection"
-    MENU_ITEM = "Menu item"
+    MENU_ITEM = "MenuItem"
     PAGE = "Page"
     PRODUCT = "Product"
     SALE = "Sale"
-    SHIPPING_METHOD = "Shipping method"
-    VARIANT = "Variant"
+    SHIPPING_METHOD = "ShippingMethod"
+    VARIANT = "ProductVariant"
     VOUCHER = "Voucher"
 
 
 class TranslationQueries(graphene.ObjectType):
+    # TODO We nead to change output of this query to new types. Issue #4957
     translations = BaseConnectionField(
         TranslatableItemConnection,
         description="Returns a list of all translatable items of a given kind.",
@@ -64,6 +98,15 @@ class TranslationQueries(graphene.ObjectType):
             TranslatableKinds, required=True, description="Kind of objects to retrieve."
         ),
     )
+    translation = graphene.Field(
+        DefaultTranslationItem,
+        id=graphene.Argument(
+            graphene.ID, description="ID of the object to retrieve.", required=True
+        ),
+        kind=graphene.Argument(
+            TranslatableKinds, required=True, description="Kind of the object to retrieve."
+        ),
+    )
 
     def resolve_translations(self, info, kind, **_kwargs):
         if kind == TranslatableKinds.PRODUCT:
@@ -88,3 +131,25 @@ def resolve_translations(self, info, kind, **_kwargs):
             return resolve_menu_items(info, query=None)
         elif kind == TranslatableKinds.SALE:
             return resolve_sales(info, query=None)
+
+    @permission_required("site.manage_translations")
+    def resolve_translation(self, info, id, kind, **_kwargs):
+        # Disable all the no-member violations in this function
+        # pylint: disable=no-member
+        _type, kind_id = graphene.Node.from_global_id(id)
+        if not _type == kind:
+            return None
+        models = {
+            TranslatableKinds.PRODUCT.value: Product,
+            TranslatableKinds.COLLECTION.value: Collection,
+            TranslatableKinds.CATEGORY.value: Category,
+            TranslatableKinds.ATTRIBUTE.value: Attribute,
+            TranslatableKinds.ATTRIBUTE_VALUE.value: AttributeValue,
+            TranslatableKinds.VARIANT.value: ProductVariant,
+            TranslatableKinds.PAGE.value: Page,
+            TranslatableKinds.SHIPPING_METHOD.value: ShippingMethod,
+            TranslatableKinds.SALE.value: Sale,
+            TranslatableKinds.VOUCHER.value: Voucher,
+            TranslatableKinds.MENU_ITEM.value: MenuItem,
+        }
+        return models[kind].objects.filter(pk=kind_id).first()
diff --git a/saleor/graphql/translations/types.py b/saleor/graphql/translations/types.py
--- a/saleor/graphql/translations/types.py
+++ b/saleor/graphql/translations/types.py
@@ -10,7 +10,19 @@
 from ..core.connection import CountableDjangoObjectType
 from ..core.types import LanguageDisplay
 from ..core.utils import str_to_enum
+from ..decorators import permission_required
 from .enums import LanguageCodeEnum
+from .resolvers import resolve_translation
+
+BASIC_TRANSLATABLE_FIELDS = ["id", "name"]
+EXTENDED_TRANSLATABLE_FIELDS = [
+    "id",
+    "name",
+    "description",
+    "description_json",
+    "seo_title",
+    "seo_description",
+]
 
 
 class BaseTranslationType(CountableDjangoObjectType):
@@ -40,68 +52,258 @@ class AttributeValueTranslation(BaseTranslationType):
     class Meta:
         model = product_models.AttributeValueTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class AttributeValueTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        AttributeValueTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated Attribute Value fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    attribute_value = graphene.Field(
+        "saleor.graphql.product.types.attributes.AttributeValue",
+        description="Represents a value of an attribute.",
+    )
+
+    class Meta:
+        model = product_models.AttributeValue
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_attribute_value(root: product_models.AttributeValue, _info):
+        return root
 
 
 class AttributeTranslation(BaseTranslationType):
     class Meta:
         model = product_models.AttributeTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class AttributeTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        AttributeTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated Attribute fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    attribute = graphene.Field(
+        "saleor.graphql.product.types.attributes.Attribute",
+        description="Custom attribute of a product.",
+    )
+
+    class Meta:
+        model = product_models.Attribute
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_attribute(root: product_models.Attribute, _info):
+        return root
 
 
 class ProductVariantTranslation(BaseTranslationType):
     class Meta:
         model = product_models.ProductVariantTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class ProductVariantTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        ProductVariantTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated Product Variant fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    product_variant = graphene.Field(
+        "saleor.graphql.product.types.products.ProductVariant",
+        description=(
+            "Represents a version of a product such as different size or color."
+        ),
+    )
+
+    class Meta:
+        model = product_models.ProductVariant
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_product_variant(root: product_models.ProductVariant, info):
+        visible_products = product_models.Product.objects.visible_to_user(
+            info.context.user
+        ).values_list("pk", flat=True)
+        return product_models.ProductVariant.objects.filter(
+            product__id__in=visible_products, pk=root.id
+        ).first()
 
 
 class ProductTranslation(BaseTranslationType):
     class Meta:
         model = product_models.ProductTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = [
-            "description",
-            "description_json",
-            "id",
-            "name",
-            "seo_title",
-            "seo_description",
-        ]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+
+class ProductTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        ProductTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=("Returns translated Product fields for the given language code."),
+        resolver=resolve_translation,
+    )
+    product = graphene.Field(
+        "saleor.graphql.product.types.products.Product",
+        description="Represents an individual item for sale in the storefront.",
+    )
+
+    class Meta:
+        model = product_models.Product
+        interfaces = [graphene.relay.Node]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_product(root: product_models.Product, info):
+        return (
+            product_models.Product.objects.visible_to_user(info.context.user)
+            .filter(pk=root.id)
+            .first()
+        )
 
 
 class CollectionTranslation(BaseTranslationType):
     class Meta:
         model = product_models.CollectionTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = [
-            "description",
-            "description_json",
-            "id",
-            "name",
-            "seo_title",
-            "seo_description",
-        ]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+
+class CollectionTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        CollectionTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated Collection fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    collection = graphene.Field(
+        "saleor.graphql.product.types.products.Collection",
+        description="Represents a collection of products.",
+    )
+
+    class Meta:
+        model = product_models.Collection
+        interfaces = [graphene.relay.Node]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_collection(root: product_models.Collection, info):
+        return (
+            product_models.Collection.objects.visible_to_user(info.context.user)
+            .filter(pk=root.id)
+            .first()
+        )
 
 
 class CategoryTranslation(BaseTranslationType):
     class Meta:
         model = product_models.CategoryTranslation
         interfaces = [graphene.relay.Node]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+
+class CategoryTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        CategoryTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=("Returns translated Category fields for the given language code."),
+        resolver=resolve_translation,
+    )
+    category = graphene.Field(
+        "saleor.graphql.product.types.products.Category",
+        description="Represents a single category of products.",
+    )
+
+    class Meta:
+        model = product_models.Category
+        interfaces = [graphene.relay.Node]
+        only_fields = EXTENDED_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_category(root: product_models.Category, _info):
+        return root
+
+
+class PageTranslation(BaseTranslationType):
+    class Meta:
+        model = page_models.PageTranslation
+        interfaces = [graphene.relay.Node]
         only_fields = [
-            "description",
-            "description_json",
+            "content",
+            "content_json",
             "id",
-            "name",
-            "seo_title",
             "seo_description",
+            "seo_title",
+            "title",
         ]
 
 
-class PageTranslation(BaseTranslationType):
+class PageTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        PageTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description="Returns translated Page fields for the given language code.",
+        resolver=resolve_translation,
+    )
+    page = graphene.Field(
+        "saleor.graphql.page.types.Page",
+        description=(
+            "A static page that can be manually added by a shop operator ",
+            "through the dashboard.",
+        ),
+    )
+
     class Meta:
-        model = page_models.PageTranslation
+        model = page_models.Page
         interfaces = [graphene.relay.Node]
         only_fields = [
             "content",
@@ -112,19 +314,89 @@ class Meta:
             "title",
         ]
 
+    @staticmethod
+    def resolve_page(root: page_models.Page, info):
+        return (
+            page_models.Page.objects.visible_to_user(info.context.user)
+            .filter(pk=root.id)
+            .first()
+        )
+
 
 class VoucherTranslation(BaseTranslationType):
     class Meta:
         model = discount_models.VoucherTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class VoucherTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        VoucherTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description="Returns translated Voucher fields for the given language code.",
+        resolver=resolve_translation,
+    )
+    voucher = graphene.Field(
+        "saleor.graphql.discount.types.Voucher",
+        # TODO consider move description to variable. Issue #4957
+        description=(
+            "Vouchers allow giving discounts to particular customers on categories, "
+            "collections or specific products. They can be used during checkout by "
+            "providing valid voucher codes."
+        ),
+    )
+
+    class Meta:
+        model = discount_models.Voucher
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    @permission_required("discount.manage_discounts")
+    def resolve_voucher(root: discount_models.Voucher, _info):
+        return root
 
 
 class SaleTranslation(BaseTranslationType):
     class Meta:
         model = discount_models.SaleTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class SaleTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        SaleTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description="Returns translated sale fields for the given language code.",
+        resolver=resolve_translation,
+    )
+    sale = graphene.Field(
+        "saleor.graphql.discount.types.Sale",
+        description=(
+            "Sales allow creating discounts for categories, collections "
+            "or products and are visible to all the customers."
+        ),
+    )
+
+    class Meta:
+        model = discount_models.Sale
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    @permission_required("discount.manage_discounts")
+    def resolve_sale(root: discount_models.Sale, _info):
+        return root
 
 
 class ShopTranslation(BaseTranslationType):
@@ -138,11 +410,74 @@ class MenuItemTranslation(BaseTranslationType):
     class Meta:
         model = menu_models.MenuItemTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class MenuItemTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        MenuItemTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated Menu item fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    menu_item = graphene.Field(
+        "saleor.graphql.menu.types.MenuItem",
+        description=(
+            "Represents a single item of the related menu. Can store categories, "
+            "collection or pages."
+        ),
+    )
+
+    class Meta:
+        model = menu_models.MenuItem
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    def resolve_menu_item(root: menu_models.MenuItem, _info):
+        return root
 
 
 class ShippingMethodTranslation(BaseTranslationType):
     class Meta:
         model = shipping_models.ShippingMethodTranslation
         interfaces = [graphene.relay.Node]
-        only_fields = ["id", "name"]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+
+class ShippingMethodTranslatableContent(CountableDjangoObjectType):
+    translation = graphene.Field(
+        ShippingMethodTranslation,
+        language_code=graphene.Argument(
+            LanguageCodeEnum,
+            description="A language code to return the translation for.",
+            required=True,
+        ),
+        description=(
+            "Returns translated shipping method fields for the given language code."
+        ),
+        resolver=resolve_translation,
+    )
+    shipping_method = graphene.Field(
+        "saleor.graphql.shipping.types.ShippingMethod",
+        description=(
+            "Shipping method are the methods you'll use to get customer's orders "
+            " to them. They are directly exposed to the customers."
+        ),
+    )
+
+    class Meta:
+        model = shipping_models.ShippingMethod
+        interfaces = [graphene.relay.Node]
+        only_fields = BASIC_TRANSLATABLE_FIELDS
+
+    @staticmethod
+    @permission_required("shipping.manage_shipping")
+    def resolve_shipping_method(root: shipping_models.ShippingMethod, _info):
+        return root


Test patch:
diff --git a/tests/api/test_translations.py b/tests/api/test_translations.py
--- a/tests/api/test_translations.py
+++ b/tests/api/test_translations.py
@@ -1,8 +1,10 @@
 import graphene
 import pytest
+from django.contrib.auth.models import Permission
 
 from saleor.graphql.translations.schema import TranslatableKinds
-from tests.api.utils import get_graphql_content
+from saleor.graphql.translations.types import LanguageCodeEnum
+from tests.api.utils import assert_no_permission, get_graphql_content
 
 
 def test_product_translation(user_api_client, product):
@@ -1361,3 +1363,633 @@ def test_translations_query_inline_fragment(user_api_client, product):
 
     assert data["node"]["name"] == "Test product"
     assert data["node"]["translation"]["name"] == "Produkt testowy"
+
+
+QUERY_TRANSLATION_PRODUCT = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on ProductTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                product{
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "is_published, perm_codenames, return_product",
+    [
+        (True, ["manage_translations"], True),
+        (False, ["manage_translations"], False),
+        (False, ["manage_translations", "manage_products"], True),
+    ],
+)
+def test_translation_query_product(
+    staff_api_client,
+    product,
+    product_translation_fr,
+    is_published,
+    perm_codenames,
+    return_product,
+):
+    product.is_published = is_published
+    product.save()
+
+    product_id = graphene.Node.to_global_id("Product", product.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": product_id,
+        "kind": TranslatableKinds.PRODUCT.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_PRODUCT, variables, permissions=perms
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == product.name
+    assert data["translation"]["name"] == product_translation_fr.name
+    if return_product:
+        assert data["product"]["name"] == product.name
+    else:
+        assert not data["product"]
+
+
+QUERY_TRANSLATION_COLLECTION = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on CollectionTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                collection{
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "is_published, perm_codenames, return_collection",
+    [
+        (True, ["manage_translations"], True),
+        (False, ["manage_translations"], False),
+        (False, ["manage_translations", "manage_products"], True),
+    ],
+)
+def test_translation_query_collection(
+    staff_api_client,
+    collection,
+    collection_translation_fr,
+    is_published,
+    perm_codenames,
+    return_collection,
+):
+    collection.is_published = is_published
+    collection.save()
+
+    collection_id = graphene.Node.to_global_id("Collection", collection.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": collection_id,
+        "kind": TranslatableKinds.COLLECTION.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_COLLECTION, variables, permissions=perms
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == collection.name
+    assert data["translation"]["name"] == collection_translation_fr.name
+    if return_collection:
+        assert data["collection"]["name"] == collection.name
+    else:
+        assert not data["collection"]
+
+
+QUERY_TRANSLATION_CATEGORY = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on CategoryTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                category {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+def test_translation_query_category(
+    staff_api_client, category, category_translation_fr, permission_manage_translations
+):
+    category_id = graphene.Node.to_global_id("Category", category.id)
+
+    variables = {
+        "id": category_id,
+        "kind": TranslatableKinds.CATEGORY.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_CATEGORY,
+        variables,
+        permissions=[permission_manage_translations],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == category.name
+    assert data["translation"]["name"] == category_translation_fr.name
+    assert data["category"]["name"] == category.name
+
+
+QUERY_TRANSLATION_ATTRIBUTE = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on AttributeTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                attribute {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+def test_translation_query_attribute(
+    staff_api_client, translated_attribute, permission_manage_translations
+):
+    attribute = translated_attribute.attribute
+    attribute_id = graphene.Node.to_global_id("Attribute", attribute.id)
+
+    variables = {
+        "id": attribute_id,
+        "kind": TranslatableKinds.ATTRIBUTE.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_ATTRIBUTE,
+        variables,
+        permissions=[permission_manage_translations],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == attribute.name
+    assert data["translation"]["name"] == translated_attribute.name
+    assert data["attribute"]["name"] == attribute.name
+
+
+QUERY_TRANSLATION_ATTRIBUTE_VALUE = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on AttributeValueTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                attributeValue {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+def test_translation_query_attribute_value(
+    staff_api_client,
+    pink_attribute_value,
+    translated_attribute_value,
+    permission_manage_translations,
+):
+    attribute_value_id = graphene.Node.to_global_id(
+        "AttributeValue", pink_attribute_value.id
+    )
+
+    variables = {
+        "id": attribute_value_id,
+        "kind": TranslatableKinds.ATTRIBUTE_VALUE.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_ATTRIBUTE_VALUE,
+        variables,
+        permissions=[permission_manage_translations],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == pink_attribute_value.name
+    assert data["translation"]["name"] == translated_attribute_value.name
+    assert data["attributeValue"]["name"] == pink_attribute_value.name
+
+
+QUERY_TRANSLATION_VARIANT = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on ProductVariantTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                productVariant {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "is_published, perm_codenames, return_variant",
+    [
+        (True, ["manage_translations"], True),
+        (False, ["manage_translations"], False),
+        (False, ["manage_translations", "manage_products"], True),
+    ],
+)
+def test_translation_query_variant(
+    staff_api_client,
+    product,
+    variant,
+    variant_translation_fr,
+    is_published,
+    perm_codenames,
+    return_variant,
+):
+    product.is_published = is_published
+    product.save()
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": variant_id,
+        "kind": TranslatableKinds.VARIANT.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_VARIANT, variables, permissions=perms
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == variant.name
+    assert data["translation"]["name"] == variant_translation_fr.name
+    if return_variant:
+        assert data["productVariant"]["name"] == variant.name
+    else:
+        assert not data["productVariant"]
+
+
+QUERY_TRANSLATION_PAGE = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on PageTranslatableContent{
+                id
+                title
+                translation(languageCode: $languageCode){
+                    title
+                }
+                page {
+                    id
+                    title
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "is_published, perm_codenames, return_page",
+    [
+        (True, ["manage_translations"], True),
+        (False, ["manage_translations"], False),
+        (False, ["manage_translations", "manage_pages"], True),
+    ],
+)
+def test_translation_query_page(
+    staff_api_client,
+    page,
+    page_translation_fr,
+    is_published,
+    perm_codenames,
+    return_page,
+):
+    page.is_published = is_published
+    page.save()
+
+    page_id = graphene.Node.to_global_id("Page", page.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": page_id,
+        "kind": TranslatableKinds.PAGE.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_PAGE, variables, permissions=perms
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["title"] == page.title
+    assert data["translation"]["title"] == page_translation_fr.title
+    if return_page:
+        assert data["page"]["title"] == page.title
+    else:
+        assert not data["page"]
+
+
+QUERY_TRANSLATION_SHIPPING_METHOD = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on ShippingMethodTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                shippingMethod {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "perm_codenames, return_shipping_method",
+    [
+        (["manage_translations"], False),
+        (["manage_translations", "manage_shipping"], True),
+    ],
+)
+def test_translation_query_shipping_method(
+    staff_api_client,
+    shipping_method,
+    shipping_method_translation_fr,
+    perm_codenames,
+    return_shipping_method,
+):
+    shipping_method_id = graphene.Node.to_global_id(
+        "ShippingMethod", shipping_method.id
+    )
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": shipping_method_id,
+        "kind": TranslatableKinds.SHIPPING_METHOD.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_SHIPPING_METHOD, variables, permissions=perms
+    )
+    content = get_graphql_content(response, ignore_errors=True)
+    data = content["data"]["translation"]
+    assert data["name"] == shipping_method.name
+    assert data["translation"]["name"] == shipping_method_translation_fr.name
+    if return_shipping_method:
+        assert data["shippingMethod"]["name"] == shipping_method.name
+    else:
+        assert not data["shippingMethod"]
+
+
+QUERY_TRANSLATION_SALE = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on SaleTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                sale {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "perm_codenames, return_sale",
+    [
+        (["manage_translations"], False),
+        (["manage_translations", "manage_discounts"], True),
+    ],
+)
+def test_translation_query_sale(
+    staff_api_client, sale, sale_translation_fr, perm_codenames, return_sale
+):
+    sale_id = graphene.Node.to_global_id("Sale", sale.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": sale_id,
+        "kind": TranslatableKinds.SALE.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_SALE, variables, permissions=perms
+    )
+    content = get_graphql_content(response, ignore_errors=True)
+    data = content["data"]["translation"]
+    assert data["name"] == sale.name
+    assert data["translation"]["name"] == sale_translation_fr.name
+    if return_sale:
+        assert data["sale"]["name"] == sale.name
+    else:
+        assert not data["sale"]
+
+
+QUERY_TRANSLATION_VOUCHER = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on VoucherTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                voucher {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "perm_codenames, return_voucher",
+    [
+        (["manage_translations"], False),
+        (["manage_translations", "manage_discounts"], True),
+    ],
+)
+def test_translation_query_voucher(
+    staff_api_client, voucher, voucher_translation_fr, perm_codenames, return_voucher
+):
+    voucher_id = graphene.Node.to_global_id("Voucher", voucher.id)
+    perms = list(Permission.objects.filter(codename__in=perm_codenames))
+
+    variables = {
+        "id": voucher_id,
+        "kind": TranslatableKinds.VOUCHER.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_VOUCHER, variables, permissions=perms
+    )
+    content = get_graphql_content(response, ignore_errors=True)
+    data = content["data"]["translation"]
+    assert data["name"] == voucher.name
+    assert data["translation"]["name"] == voucher_translation_fr.name
+    if return_voucher:
+        assert data["voucher"]["name"] == voucher.name
+    else:
+        assert not data["voucher"]
+
+
+QUERY_TRANSLATION_MENU_ITEM = """
+    query translation(
+        $kind: TranslatableKinds!, $id: ID!, $languageCode: LanguageCodeEnum!
+    ){
+        translation(kind: $kind, id: $id){
+            __typename
+            ...on MenuItemTranslatableContent{
+                id
+                name
+                translation(languageCode: $languageCode){
+                    name
+                }
+                menuItem {
+                    id
+                    name
+                }
+            }
+        }
+    }
+"""
+
+
+def test_translation_query_menu_item(
+    staff_api_client,
+    menu_item,
+    menu_item_translation_fr,
+    permission_manage_translations,
+):
+    menu_item_id = graphene.Node.to_global_id("MenuItem", menu_item.id)
+
+    variables = {
+        "id": menu_item_id,
+        "kind": TranslatableKinds.MENU_ITEM.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_MENU_ITEM,
+        variables,
+        permissions=[permission_manage_translations],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["translation"]
+    assert data["name"] == menu_item.name
+    assert data["translation"]["name"] == menu_item_translation_fr.name
+    assert data["menuItem"]["name"] == menu_item.name
+
+
+def test_translation_query_incorrect_kind(
+    staff_api_client, menu_item, permission_manage_translations
+):
+    menu_item_id = graphene.Node.to_global_id("MenuItem", menu_item.id)
+
+    variables = {
+        "id": menu_item_id,
+        "kind": TranslatableKinds.PRODUCT.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(
+        QUERY_TRANSLATION_MENU_ITEM,
+        variables,
+        permissions=[permission_manage_translations],
+    )
+    content = get_graphql_content(response)
+    assert not content["data"]["translation"]
+
+
+def test_translation_query_no_permission(staff_api_client, menu_item):
+    menu_item_id = graphene.Node.to_global_id("MenuItem", menu_item.id)
+
+    variables = {
+        "id": menu_item_id,
+        "kind": TranslatableKinds.MENU_ITEM.name,
+        "languageCode": LanguageCodeEnum.FR.name,
+    }
+    response = staff_api_client.post_graphql(QUERY_TRANSLATION_MENU_ITEM, variables)
+    assert_no_permission(response)
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,16 +22,22 @@
 from saleor.checkout.utils import add_variant_to_checkout
 from saleor.core.payments import PaymentInterface
 from saleor.discount import DiscountInfo, DiscountValueType, VoucherType
-from saleor.discount.models import Sale, Voucher, VoucherCustomer, VoucherTranslation
+from saleor.discount.models import (
+    Sale,
+    SaleTranslation,
+    Voucher,
+    VoucherCustomer,
+    VoucherTranslation,
+)
 from saleor.giftcard.models import GiftCard
-from saleor.menu.models import Menu, MenuItem
+from saleor.menu.models import Menu, MenuItem, MenuItemTranslation
 from saleor.menu.utils import update_menu
 from saleor.order import OrderStatus
 from saleor.order.actions import fulfill_order_line
 from saleor.order.events import OrderEvents
 from saleor.order.models import FulfillmentStatus, Order, OrderEvent
 from saleor.order.utils import recalculate_order
-from saleor.page.models import Page
+from saleor.page.models import Page, PageTranslation
 from saleor.payment import ChargeStatus, TransactionKind
 from saleor.payment.models import Payment
 from saleor.product import AttributeInputType
@@ -39,8 +45,11 @@
     Attribute,
     AttributeTranslation,
     AttributeValue,
+    AttributeValueTranslation,
     Category,
+    CategoryTranslation,
     Collection,
+    CollectionTranslation,
     DigitalContent,
     DigitalContentUrl,
     Product,
@@ -48,9 +57,15 @@
     ProductTranslation,
     ProductType,
     ProductVariant,
+    ProductVariantTranslation,
 )
 from saleor.product.utils.attributes import associate_attribute_values_to_instance
-from saleor.shipping.models import ShippingMethod, ShippingMethodType, ShippingZone
+from saleor.shipping.models import (
+    ShippingMethod,
+    ShippingMethodTranslation,
+    ShippingMethodType,
+    ShippingZone,
+)
 from saleor.site import AuthenticationBackends
 from saleor.site.models import AuthorizationKey, SiteSettings
 from saleor.webhook import WebhookEventType
@@ -1266,7 +1281,16 @@ def translated_variant_fr(product):
 def translated_attribute(product):
     attribute = product.product_type.product_attributes.first()
     return AttributeTranslation.objects.create(
-        language_code="fr", attribute=attribute, name="Name tranlsated to french"
+        language_code="fr", attribute=attribute, name="French attribute name"
+    )
+
+
+@pytest.fixture
+def translated_attribute_value(pink_attribute_value):
+    return AttributeValueTranslation.objects.create(
+        language_code="fr",
+        attribute_value=pink_attribute_value,
+        name="French attribute value name",
     )
 
 
@@ -1287,6 +1311,66 @@ def product_translation_fr(product):
     )
 
 
+@pytest.fixture
+def variant_translation_fr(variant):
+    return ProductVariantTranslation.objects.create(
+        language_code="fr", product_variant=variant, name="French product variant name"
+    )
+
+
+@pytest.fixture
+def collection_translation_fr(collection):
+    return CollectionTranslation.objects.create(
+        language_code="fr",
+        collection=collection,
+        name="French collection name",
+        description="French description",
+    )
+
+
+@pytest.fixture
+def category_translation_fr(category):
+    return CategoryTranslation.objects.create(
+        language_code="fr",
+        category=category,
+        name="French category name",
+        description="French category description",
+    )
+
+
+@pytest.fixture
+def page_translation_fr(page):
+    return PageTranslation.objects.create(
+        language_code="fr",
+        page=page,
+        title="French page title",
+        content="French page content",
+    )
+
+
+@pytest.fixture
+def shipping_method_translation_fr(shipping_method):
+    return ShippingMethodTranslation.objects.create(
+        language_code="fr",
+        shipping_method=shipping_method,
+        name="French shipping method name",
+    )
+
+
+@pytest.fixture
+def sale_translation_fr(sale):
+    return SaleTranslation.objects.create(
+        language_code="fr", sale=sale, name="French sale name"
+    )
+
+
+@pytest.fixture
+def menu_item_translation_fr(menu_item):
+    return MenuItemTranslation.objects.create(
+        language_code="fr", menu_item=menu_item, name="French manu item name"
+    )
+
+
 @pytest.fixture
 def payment_dummy(db, order_with_lines):
     return Payment.objects.create(