From 2d24746509126a91cf4818aeb8db7f17aad8fc67 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 3 Mar 2017 11:03:41 +0100 Subject: [PATCH 1/7] Implementation and tests of the @translations endpoint to get the translations of a content object, and to link and unlink translations of a content object --- src/plone/restapi/services/configure.zcml | 2 + .../restapi/services/multilingual/__init__.py | 0 .../services/multilingual/configure.zcml | 30 +++ .../restapi/services/multilingual/pam.py | 114 +++++++++++ src/plone/restapi/testing.py | 45 +++++ src/plone/restapi/tests/test_translations.py | 186 ++++++++++++++++++ 6 files changed, 377 insertions(+) create mode 100644 src/plone/restapi/services/multilingual/__init__.py create mode 100644 src/plone/restapi/services/multilingual/configure.zcml create mode 100644 src/plone/restapi/services/multilingual/pam.py create mode 100644 src/plone/restapi/tests/test_translations.py diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml index 0ef3143b57..a567103add 100644 --- a/src/plone/restapi/services/configure.zcml +++ b/src/plone/restapi/services/configure.zcml @@ -26,5 +26,7 @@ + diff --git a/src/plone/restapi/services/multilingual/__init__.py b/src/plone/restapi/services/multilingual/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plone/restapi/services/multilingual/configure.zcml b/src/plone/restapi/services/multilingual/configure.zcml new file mode 100644 index 0000000000..c3ca9f36c7 --- /dev/null +++ b/src/plone/restapi/services/multilingual/configure.zcml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/src/plone/restapi/services/multilingual/pam.py b/src/plone/restapi/services/multilingual/pam.py new file mode 100644 index 0000000000..bb8030c7bf --- /dev/null +++ b/src/plone/restapi/services/multilingual/pam.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +from zope.interface import alsoProvides +from Products.CMFCore.utils import getToolByName +from plone.app.multilingual.interfaces import ITranslationManager +from plone.restapi.services import Service +from plone.restapi.deserializer import json_body +from Products.CMFPlone.interfaces import ILanguage + +import plone.protect.interfaces + + +class TranslationInfo(Service): + """ Get translation information + """ + + def reply(self): + manager = ITranslationManager(self.context) + info = { + '@id': self.context.absolute_url(), + 'language': ILanguage(self.context).get_language(), + 'translations': []} + for language, translation in manager.get_translations().items(): + if language != ILanguage(self.context).get_language(): + info['translations'].append({ + '@id': translation.absolute_url(), + 'language': language, + }) + + return info + + +class LinkTranslations(Service): + """ Link two content objects as translations of each other + """ + + def reply(self): + # Disable CSRF protection + if 'IDisableCSRFProtection' in dir(plone.protect.interfaces): + alsoProvides(self.request, + plone.protect.interfaces.IDisableCSRFProtection) + + data = json_body(self.request) + id_ = data.get('id', None) + if id_ is None: + self.request.response.setStatus(400) + return dict(error=dict( + type='BadRequest', + message='Missing content id to link to')) + + target = self._traverse(id_) + if target is None: + self.request.response.setStatus(400) + return dict(error=dict( + type='BadRequest', + message='Content does not exist')) + + target_language = ILanguage(target).get_language() + manager = ITranslationManager(self.context) + current_translation = manager.get_translation(target_language) + if current_translation is not None: + self.request.response.setStatus(400) + return dict(error=dict( + type='BadRequest', + message='Already translated into language {}'.format( + target_language))) + + manager.register_translation(target_language, target) + self.request.response.setStatus(201) + self.request.response.setHeader( + 'Location', self.context.absolute_url()) + return {} + + def _traverse(self, url): + purl = getToolByName(self.context, 'portal_url') + portal = purl.getPortalObject() + portal_url = portal.absolute_url() + if url.startswith(portal_url): + content_path = url[len(portal_url)+1:] + content_path = content_path.split('/') + content_item = portal.restrictedTraverse(content_path) + return content_item + + return None + + +class UnlinkTranslations(Service): + """ Unlink the translations for a content object + """ + + def reply(self): + # Disable CSRF protection + if 'IDisableCSRFProtection' in dir(plone.protect.interfaces): + alsoProvides(self.request, + plone.protect.interfaces.IDisableCSRFProtection) + + data = json_body(self.request) + manager = ITranslationManager(self.context) + language = data.get('language', None) + if language is None: + self.request.response.setStatus(400) + return dict(error=dict( + type='BadRequest', + message='You need to provide the language to unlink')) + + if language not in manager.get_translations().keys(): + self.request.response.setStatus(400) + return dict(error=dict( + type='BadRequest', + message='This objects is not translated into {}'.format( + language))) + + manager.remove_translation(language) + self.request.response.setStatus(204) + return {} diff --git a/src/plone/restapi/testing.py b/src/plone/restapi/testing.py index a14087687e..932a7ee9e0 100644 --- a/src/plone/restapi/testing.py +++ b/src/plone/restapi/testing.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from Products.CMFCore.utils import getToolByName # pylint: disable=E1002 # E1002: Use of super on an old style class @@ -88,6 +89,50 @@ def setUpPloneSite(self, portal): ) +class PloneRestApiDXPAMLayer(PloneSandboxLayer): + defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) + + def setUpZope(self, app, configurationContext): + import plone.restapi + xmlconfig.file( + 'configure.zcml', + plone.restapi, + context=configurationContext + ) + xmlconfig.file( + 'testing.zcml', + plone.restapi, + context=configurationContext + ) + + z2.installProduct(app, 'plone.restapi') + + def setUpPloneSite(self, portal): + portal.acl_users.userFolderAddUser( + SITE_OWNER_NAME, SITE_OWNER_PASSWORD, ['Manager'], []) + login(portal, SITE_OWNER_NAME) + setRoles(portal, TEST_USER_ID, ['Manager']) + language_tool = getToolByName(portal, 'portal_languages') + language_tool.addSupportedLanguage('en') + language_tool.addSupportedLanguage('es') + applyProfile(portal, 'plone.app.multilingual:default') + applyProfile(portal, 'plone.restapi:default') + applyProfile(portal, 'plone.restapi:testing') + add_catalog_indexes(portal, DX_TYPES_INDEXES) + set_available_languages() + + +PLONE_RESTAPI_DX_PAM_FIXTURE = PloneRestApiDXPAMLayer() +PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING = IntegrationTesting( + bases=(PLONE_RESTAPI_DX_PAM_FIXTURE,), + name="PloneRestApiDXPAMLayer:Integration" +) +PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING = FunctionalTesting( + bases=(PLONE_RESTAPI_DX_PAM_FIXTURE, z2.ZSERVER_FIXTURE), + name="PloneRestApiDXPAMLayer:Functional" +) + + class PloneRestApiATLayer(PloneSandboxLayer): defaultBases = (PLONE_FIXTURE,) diff --git a/src/plone/restapi/tests/test_translations.py b/src/plone/restapi/tests/test_translations.py new file mode 100644 index 0000000000..2ebf58b5fb --- /dev/null +++ b/src/plone/restapi/tests/test_translations.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +from plone.restapi.testing import PAM_INSTALLED + +import unittest + + +if PAM_INSTALLED: + from plone.app.multilingual.interfaces import IPloneAppMultilingualInstalled # noqa + from plone.app.multilingual.interfaces import ITranslationManager + from plone.app.testing import login + from plone.app.testing import SITE_OWNER_NAME + from plone.app.testing import SITE_OWNER_PASSWORD + from plone.dexterity.utils import createContentInContainer + from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING + from Products.CMFPlone.interfaces import ILanguage + from zope.component import getMultiAdapter + from zope.interface import alsoProvides + + import requests + import transaction + + class TestTranslationInfo(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + + def test_translation_info_includes_translations(self): + tinfo = getMultiAdapter( + (self.en_content, self.request), + name=u'GET_application_json_@translations') + + info = tinfo.reply() + self.assertIn('translations', info) + self.assertEqual(1, len(info['translations'])) + + def test_correct_translation_information(self): + tinfo = getMultiAdapter( + (self.en_content, self.request), + name=u'GET_application_json_@translations') + + info = tinfo.reply() + tinfo_es = info['translations'][0] + self.assertEqual( + self.es_content.absolute_url(), + tinfo_es['@id']) + self.assertEqual( + ILanguage(self.es_content).get_language(), + tinfo_es['language']) + + class TestLinkContentsAsTranslations(unittest.TestCase): + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + transaction.commit() + + def test_translation_linking_succeeds(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "id": self.es_content.absolute_url(), + }, + ) + self.assertEqual(201, response.status_code) + transaction.begin() + manager = ITranslationManager(self.en_content) + for language, translation in manager.get_translations(): + if language == ILanguage(self.es_content).get_language(): + self.assertEqual(translation, self.es_content) + + def test_calling_endpoint_without_id_gives_400(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_an_already_translated_content_gives_400(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + 'id': self.es_content.absolute_url() + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_inexistent_content_gives_400(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + 'id': 'http://this-content-does-not-exist', + }, + ) + self.assertEqual(400, response.status_code) + + class TestUnLinkContentTranslations(unittest.TestCase): + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + + def test_translation_unlinking_succeeds(self): + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "language": "es", + }, + ) + self.assertEqual(204, response.status_code) + transaction.begin() + manager = ITranslationManager(self.en_content) + self.assertNotIn( + ILanguage(self.es_content).get_language(), + manager.get_translations().keys()) + + def test_calling_endpoint_without_language_gives_400(self): + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_an_untranslated_content_gives_400(self): + ITranslationManager(self.en_content).remove_translation("es") + transaction.commit() + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "language": "es", + }, + ) + self.assertEqual(400, response.status_code) +else: + + class TestDummy(unittest.TestCase): + + def test_dummy(self): + self.assertEqual(1, 1) From 80b0ff5fe7e9b3ac2a77008cea5f68a63e9a18c6 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Tue, 2 May 2017 08:57:21 +0200 Subject: [PATCH 2/7] Documentation about @translations endpoint --- docs/source/_json/translations_delete.req | 6 ++ docs/source/_json/translations_delete.resp | 2 + docs/source/_json/translations_get.req | 3 + docs/source/_json/translations_get.resp | 13 ++++ docs/source/_json/translations_post.req | 6 ++ docs/source/_json/translations_post.resp | 5 ++ docs/source/index.rst | 2 +- docs/source/translations.rst | 60 +++++++++++++++ src/plone/restapi/testing.py | 8 ++ src/plone/restapi/tests/test_documentation.py | 74 +++++++++++++++++++ 10 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 docs/source/_json/translations_delete.req create mode 100644 docs/source/_json/translations_delete.resp create mode 100644 docs/source/_json/translations_get.req create mode 100644 docs/source/_json/translations_get.resp create mode 100644 docs/source/_json/translations_post.req create mode 100644 docs/source/_json/translations_post.resp create mode 100644 docs/source/translations.rst diff --git a/docs/source/_json/translations_delete.req b/docs/source/_json/translations_delete.req new file mode 100644 index 0000000000..a753cb69be --- /dev/null +++ b/docs/source/_json/translations_delete.req @@ -0,0 +1,6 @@ +DELETE /plone/en/test-document/@translations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{"language": "es"} \ No newline at end of file diff --git a/docs/source/_json/translations_delete.resp b/docs/source/_json/translations_delete.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/docs/source/_json/translations_delete.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/docs/source/_json/translations_get.req b/docs/source/_json/translations_get.req new file mode 100644 index 0000000000..e17d071bdc --- /dev/null +++ b/docs/source/_json/translations_get.req @@ -0,0 +1,3 @@ +GET /plone/en/test-document/@translations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/docs/source/_json/translations_get.resp b/docs/source/_json/translations_get.resp new file mode 100644 index 0000000000..f27b50ee45 --- /dev/null +++ b/docs/source/_json/translations_get.resp @@ -0,0 +1,13 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/en/test-document", + "language": "en", + "translations": [ + { + "@id": "http://localhost:55001/plone/es/test-document", + "language": "es" + } + ] +} \ No newline at end of file diff --git a/docs/source/_json/translations_post.req b/docs/source/_json/translations_post.req new file mode 100644 index 0000000000..708288fd65 --- /dev/null +++ b/docs/source/_json/translations_post.req @@ -0,0 +1,6 @@ +POST /plone/en/test-document/@translations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{"id": "http://localhost:55001/plone/es/test-document"} \ No newline at end of file diff --git a/docs/source/_json/translations_post.resp b/docs/source/_json/translations_post.resp new file mode 100644 index 0000000000..b0d80491db --- /dev/null +++ b/docs/source/_json/translations_post.resp @@ -0,0 +1,5 @@ +HTTP/1.1 201 Created +Content-Type: application/json +Location: http://localhost:55001/plone/en/test-document + +{} \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 99a2ec55c5..0463af0777 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,6 +45,7 @@ Contents controlpanels customization conventions + translations .. include:: ../../README.rst @@ -58,4 +59,3 @@ Appendix, Indices and tables glossary * :ref:`genindex` - diff --git a/docs/source/translations.rst b/docs/source/translations.rst new file mode 100644 index 0000000000..3817ee92e0 --- /dev/null +++ b/docs/source/translations.rst @@ -0,0 +1,60 @@ +Translations +============ + +Since Plone 5 the product `plone.app.multilingual`_ is included in the base +Plone installation although it is not enabled by default. plone.restapi +provides a `@translations` endpoint to handle the translation information +of the content objects. + +Once we have installed `plone.app.multilingual`_ and enabled more than one +language we can link two content-items of different languages to be the +translation of each other issuing a `POST` query to the `@translations` +endpoint including the `id` of the content which should be linked to. The +`id` of the content must be a full URL of the content object: + + +.. http:example:: curl httpie python-requests + :request: _json/translations_post.req + + +.. note:: + "id" is a required field and needs to point to an existing content on the site. + +The API will return a `201 Created` response if the linking was successful. + + +.. literalinclude:: _json/translations_post.resp + :language: http + + +After linking the contents we can get the list of the translations of that +content item by issuing a ``GET`` request on the `@translations` endpoint of +that content item.: + +.. http:example:: curl httpie python-requests + :request: _json/translations_get.req + +.. literalinclude:: _json/translations_get.resp + :language: http + + +To unlink the content, issue a ``DELETE`` request on the `@translations` +endpoint of the content item and provide the language code you want to unlink.: + + +.. http:example:: curl httpie python-requests + :request: _json/translations_delete.req + +.. note:: + "language" is a required field. + +.. literalinclude:: _json/translations_delete.resp + :language: http + +.. note:: + The `@translations` endpoint works also when using `Products.LinguaPlone`_ + in Plone 4.3.x + + +.. _`plone.app.multilingual`: https://pypi.python.org/pypi/plone.app.multilingual +.. _`Products.LinguaPlone`: https://pypi.python.org/pypi/Products.LinguaPlone. diff --git a/src/plone/restapi/testing.py b/src/plone/restapi/testing.py index 932a7ee9e0..fd07589c01 100644 --- a/src/plone/restapi/testing.py +++ b/src/plone/restapi/testing.py @@ -32,6 +32,14 @@ import requests import collective.MockMailHost +import pkg_resources + + +try: + pkg_resources.get_distribution('plone.app.multilingual') + PAM_INSTALLED = True +except pkg_resources.DistributionNotFound: + PAM_INSTALLED = False def set_available_languages(): diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py index b8183c17d5..15fd69a7aa 100644 --- a/src/plone/restapi/tests/test_documentation.py +++ b/src/plone/restapi/tests/test_documentation.py @@ -20,6 +20,7 @@ from plone.namedfile.file import NamedBlobImage from plone.registry.interfaces import IRegistry from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import PAM_INSTALLED from plone.restapi.testing import RelativeSession from plone.restapi.testing import register_static_uuid_utility from plone.testing.z2 import Browser @@ -1225,3 +1226,76 @@ def test_controlpanels_get_item(self): '/@controlpanels/editing' ) save_request_and_response_for_docs('controlpanels_get_item', response) + +if PAM_INSTALLED: + from plone.app.multilingual.interfaces import ITranslationManager + from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + class TestPAMDocumentation(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer['app'] + self.request = self.layer['request'] + self.portal = self.layer['portal'] + self.portal_url = self.portal.absolute_url() + + self.time_freezer = freeze_time("2016-10-21 19:00:00") + self.frozen_time = self.time_freezer.start() + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({'Accept': 'application/json'}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + setRoles(self.portal, TEST_USER_ID, ['Manager']) + + language_tool = getToolByName(self.portal, 'portal_languages') + language_tool.addSupportedLanguage('en') + language_tool.addSupportedLanguage('es') + applyProfile(self.portal, 'plone.app.multilingual:default') + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + + import transaction + transaction.commit() + self.browser = Browser(self.app) + self.browser.handleErrors = False + self.browser.addHeader( + 'Authorization', + 'Basic %s:%s' % (SITE_OWNER_NAME, SITE_OWNER_PASSWORD,) + ) + + def tearDown(self): + self.time_freezer.stop() + + def test_documentation_translations_post(self): + response = self.api_session.post( + '{}/@translations'.format(self.en_content.absolute_url()), + json={ + 'id': self.es_content.absolute_url() + } + ) + save_request_and_response_for_docs('translations_post', response) + + def test_documentation_translations_get(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = self.api_session.get( + '{}/@translations'.format(self.en_content.absolute_url())) + + save_request_and_response_for_docs('translations_get', response) + + def test_documentation_translations_delete(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = self.api_session.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + json={ + "language": "es" + }) + save_request_and_response_for_docs('translations_delete', response) From 59d8c628a7a4a898ce5fcb877012e09b7f4538df Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Tue, 2 May 2017 09:37:14 +0200 Subject: [PATCH 3/7] add changelog entry --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 667fe1d535..6fa5da40c2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -138,6 +138,9 @@ Bugfixes: New Features: +- Add @translations endpoint + [erral] + - Reorder children in a item using the content endpoint. [jaroel] From 027049fea432a3a8a948d86e452167bc7a71276a Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 9 Oct 2017 10:02:58 +0200 Subject: [PATCH 4/7] rework tests, remove ugly if-s and use unittest.skipUnless --- src/plone/restapi/testing.py | 4 +- src/plone/restapi/tests/test_documentation.py | 162 ++++---- src/plone/restapi/tests/test_translations.py | 352 +++++++++--------- 3 files changed, 265 insertions(+), 253 deletions(-) diff --git a/src/plone/restapi/testing.py b/src/plone/restapi/testing.py index fd07589c01..a4d062f6f7 100644 --- a/src/plone/restapi/testing.py +++ b/src/plone/restapi/testing.py @@ -123,7 +123,9 @@ def setUpPloneSite(self, portal): language_tool = getToolByName(portal, 'portal_languages') language_tool.addSupportedLanguage('en') language_tool.addSupportedLanguage('es') - applyProfile(portal, 'plone.app.multilingual:default') + if portal.portal_setup.profileExists( + 'plone.app.multilingual:default'): + applyProfile(portal, 'plone.app.multilingual:default') applyProfile(portal, 'plone.restapi:default') applyProfile(portal, 'plone.restapi:testing') add_catalog_indexes(portal, DX_TYPES_INDEXES) diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py index 15fd69a7aa..d152d07073 100644 --- a/src/plone/restapi/tests/test_documentation.py +++ b/src/plone/restapi/tests/test_documentation.py @@ -8,21 +8,23 @@ from plone.app.discussion.interfaces import IConversation from plone.app.discussion.interfaces import IDiscussionSettings from plone.app.discussion.interfaces import IReplies -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +from plone.app.testing import applyProfile from plone.app.testing import popGlobalRegistry from plone.app.testing import pushGlobalRegistry from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID from plone.app.textfield.value import RichTextValue from plone.locking.interfaces import ITTWLockable from plone.namedfile.file import NamedBlobFile from plone.namedfile.file import NamedBlobImage from plone.registry.interfaces import IRegistry -from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING from plone.restapi.testing import PAM_INSTALLED -from plone.restapi.testing import RelativeSession +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING from plone.restapi.testing import register_static_uuid_utility +from plone.restapi.testing import RelativeSession from plone.testing.z2 import Browser from zope.component import createObject from zope.component import getUtility @@ -36,6 +38,10 @@ import transaction import unittest +if PAM_INSTALLED: + from plone.app.multilingual.interfaces import ITranslationManager + + TUS_HEADERS = [ 'upload-offset', @@ -1227,75 +1233,81 @@ def test_controlpanels_get_item(self): ) save_request_and_response_for_docs('controlpanels_get_item', response) -if PAM_INSTALLED: - from plone.app.multilingual.interfaces import ITranslationManager - from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING - - class TestPAMDocumentation(unittest.TestCase): - - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING - - def setUp(self): - self.app = self.layer['app'] - self.request = self.layer['request'] - self.portal = self.layer['portal'] - self.portal_url = self.portal.absolute_url() - - self.time_freezer = freeze_time("2016-10-21 19:00:00") - self.frozen_time = self.time_freezer.start() - - self.api_session = RelativeSession(self.portal_url) - self.api_session.headers.update({'Accept': 'application/json'}) - self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) - - setRoles(self.portal, TEST_USER_ID, ['Manager']) - - language_tool = getToolByName(self.portal, 'portal_languages') - language_tool.addSupportedLanguage('en') - language_tool.addSupportedLanguage('es') - applyProfile(self.portal, 'plone.app.multilingual:default') - self.en_content = createContentInContainer( - self.portal['en'], 'Document', title='Test document') - self.es_content = createContentInContainer( - self.portal['es'], 'Document', title='Test document') - - import transaction - transaction.commit() - self.browser = Browser(self.app) - self.browser.handleErrors = False - self.browser.addHeader( - 'Authorization', - 'Basic %s:%s' % (SITE_OWNER_NAME, SITE_OWNER_PASSWORD,) - ) - def tearDown(self): - self.time_freezer.stop() +@unittest.skipUnless(PAM_INSTALLED, 'plone.app.multilingual is installed by default only in Plone 5') +class TestPAMDocumentation(unittest.TestCase): - def test_documentation_translations_post(self): - response = self.api_session.post( - '{}/@translations'.format(self.en_content.absolute_url()), - json={ - 'id': self.es_content.absolute_url() - } - ) - save_request_and_response_for_docs('translations_post', response) - - def test_documentation_translations_get(self): - ITranslationManager(self.en_content).register_translation( - 'es', self.es_content) - transaction.commit() - response = self.api_session.get( - '{}/@translations'.format(self.en_content.absolute_url())) - - save_request_and_response_for_docs('translations_get', response) - - def test_documentation_translations_delete(self): - ITranslationManager(self.en_content).register_translation( - 'es', self.es_content) - transaction.commit() - response = self.api_session.delete( - '{}/@translations'.format(self.en_content.absolute_url()), - json={ - "language": "es" - }) - save_request_and_response_for_docs('translations_delete', response) + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer['app'] + self.request = self.layer['request'] + self.portal = self.layer['portal'] + self.portal_url = self.portal.absolute_url() + + self.time_freezer = freeze_time("2016-10-21 19:00:00") + self.frozen_time = self.time_freezer.start() + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({'Accept': 'application/json'}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + setRoles(self.portal, TEST_USER_ID, ['Manager']) + + language_tool = api.portal.get_tool('portal_languages') + language_tool.addSupportedLanguage('en') + language_tool.addSupportedLanguage('es') + applyProfile(self.portal, 'plone.app.multilingual:default') + en_id = self.portal['en'].invokeFactory( + 'Document', + id='test-document', + title='Test document' + ) + self.en_content = self.portal['en'].get(en_id) + es_id = self.portal['es'].invokeFactory( + 'Document', + id='test-document', + title='Test document' + ) + self.es_content = self.portal['es'].get(es_id) + + import transaction + transaction.commit() + self.browser = Browser(self.app) + self.browser.handleErrors = False + self.browser.addHeader( + 'Authorization', + 'Basic %s:%s' % (SITE_OWNER_NAME, SITE_OWNER_PASSWORD,) + ) + + def tearDown(self): + self.time_freezer.stop() + + def test_documentation_translations_post(self): + response = self.api_session.post( + '{}/@translations'.format(self.en_content.absolute_url()), + json={ + 'id': self.es_content.absolute_url() + } + ) + save_request_and_response_for_docs('translations_post', response) + + def test_documentation_translations_get(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = self.api_session.get( + '{}/@translations'.format(self.en_content.absolute_url())) + + save_request_and_response_for_docs('translations_get', response) + + def test_documentation_translations_delete(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = self.api_session.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + json={ + "language": "es" + }) + save_request_and_response_for_docs('translations_delete', response) diff --git a/src/plone/restapi/tests/test_translations.py b/src/plone/restapi/tests/test_translations.py index 2ebf58b5fb..09992676b1 100644 --- a/src/plone/restapi/tests/test_translations.py +++ b/src/plone/restapi/tests/test_translations.py @@ -1,186 +1,184 @@ # -*- coding: utf-8 -*- +from plone.app.testing import login +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.dexterity.utils import createContentInContainer from plone.restapi.testing import PAM_INSTALLED +from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING +from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING +from zope.component import getMultiAdapter +from zope.interface import alsoProvides +import requests +import transaction import unittest if PAM_INSTALLED: + from Products.CMFPlone.interfaces import ILanguage from plone.app.multilingual.interfaces import IPloneAppMultilingualInstalled # noqa from plone.app.multilingual.interfaces import ITranslationManager - from plone.app.testing import login - from plone.app.testing import SITE_OWNER_NAME - from plone.app.testing import SITE_OWNER_PASSWORD - from plone.dexterity.utils import createContentInContainer - from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING - from plone.restapi.testing import PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING - from Products.CMFPlone.interfaces import ILanguage - from zope.component import getMultiAdapter - from zope.interface import alsoProvides - - import requests - import transaction - - class TestTranslationInfo(unittest.TestCase): - - layer = PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING - - def setUp(self): - self.portal = self.layer['portal'] - self.request = self.layer['request'] - alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) - login(self.portal, SITE_OWNER_NAME) - self.en_content = createContentInContainer( - self.portal['en'], 'Document', title='Test document') - self.es_content = createContentInContainer( - self.portal['es'], 'Document', title='Test document') - ITranslationManager(self.en_content).register_translation( - 'es', self.es_content) - - def test_translation_info_includes_translations(self): - tinfo = getMultiAdapter( - (self.en_content, self.request), - name=u'GET_application_json_@translations') - - info = tinfo.reply() - self.assertIn('translations', info) - self.assertEqual(1, len(info['translations'])) - - def test_correct_translation_information(self): - tinfo = getMultiAdapter( - (self.en_content, self.request), - name=u'GET_application_json_@translations') - - info = tinfo.reply() - tinfo_es = info['translations'][0] - self.assertEqual( - self.es_content.absolute_url(), - tinfo_es['@id']) - self.assertEqual( - ILanguage(self.es_content).get_language(), - tinfo_es['language']) - - class TestLinkContentsAsTranslations(unittest.TestCase): - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING - - def setUp(self): - self.portal = self.layer['portal'] - self.request = self.layer['request'] - alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) - login(self.portal, SITE_OWNER_NAME) - self.en_content = createContentInContainer( - self.portal['en'], 'Document', title='Test document') - self.es_content = createContentInContainer( - self.portal['es'], 'Document', title='Test document') - transaction.commit() - - def test_translation_linking_succeeds(self): - response = requests.post( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - "id": self.es_content.absolute_url(), - }, - ) - self.assertEqual(201, response.status_code) - transaction.begin() - manager = ITranslationManager(self.en_content) - for language, translation in manager.get_translations(): - if language == ILanguage(self.es_content).get_language(): - self.assertEqual(translation, self.es_content) - - def test_calling_endpoint_without_id_gives_400(self): - response = requests.post( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - }, - ) - self.assertEqual(400, response.status_code) - - def test_calling_with_an_already_translated_content_gives_400(self): - ITranslationManager(self.en_content).register_translation( - 'es', self.es_content) - transaction.commit() - response = requests.post( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - 'id': self.es_content.absolute_url() - }, - ) - self.assertEqual(400, response.status_code) - - def test_calling_with_inexistent_content_gives_400(self): - response = requests.post( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - 'id': 'http://this-content-does-not-exist', - }, - ) - self.assertEqual(400, response.status_code) - - class TestUnLinkContentTranslations(unittest.TestCase): - layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING - - def setUp(self): - self.portal = self.layer['portal'] - self.request = self.layer['request'] - alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) - login(self.portal, SITE_OWNER_NAME) - self.en_content = createContentInContainer( - self.portal['en'], 'Document', title='Test document') - self.es_content = createContentInContainer( - self.portal['es'], 'Document', title='Test document') - ITranslationManager(self.en_content).register_translation( - 'es', self.es_content) - transaction.commit() - - def test_translation_unlinking_succeeds(self): - response = requests.delete( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - "language": "es", - }, - ) - self.assertEqual(204, response.status_code) - transaction.begin() - manager = ITranslationManager(self.en_content) - self.assertNotIn( - ILanguage(self.es_content).get_language(), - manager.get_translations().keys()) - - def test_calling_endpoint_without_language_gives_400(self): - response = requests.delete( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - }, - ) - self.assertEqual(400, response.status_code) - - def test_calling_with_an_untranslated_content_gives_400(self): - ITranslationManager(self.en_content).remove_translation("es") - transaction.commit() - response = requests.delete( - '{}/@translations'.format(self.en_content.absolute_url()), - headers={'Accept': 'application/json'}, - auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), - json={ - "language": "es", - }, - ) - self.assertEqual(400, response.status_code) -else: - - class TestDummy(unittest.TestCase): - - def test_dummy(self): - self.assertEqual(1, 1) + + + +@unittest.skipUnless(PAM_INSTALLED, 'plone.app.multilingual is installed by default only in Plone 5') +class TestTranslationInfo(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_PAM_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + + def test_translation_info_includes_translations(self): + tinfo = getMultiAdapter( + (self.en_content, self.request), + name=u'GET_application_json_@translations') + + info = tinfo.reply() + self.assertIn('translations', info) + self.assertEqual(1, len(info['translations'])) + + def test_correct_translation_information(self): + tinfo = getMultiAdapter( + (self.en_content, self.request), + name=u'GET_application_json_@translations') + + info = tinfo.reply() + tinfo_es = info['translations'][0] + self.assertEqual( + self.es_content.absolute_url(), + tinfo_es['@id']) + self.assertEqual( + ILanguage(self.es_content).get_language(), + tinfo_es['language']) + +@unittest.skipUnless(PAM_INSTALLED, 'plone.app.multilingual is installed by default only in Plone 5') +class TestLinkContentsAsTranslations(unittest.TestCase): + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + transaction.commit() + + def test_translation_linking_succeeds(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "id": self.es_content.absolute_url(), + }, + ) + self.assertEqual(201, response.status_code) + transaction.begin() + manager = ITranslationManager(self.en_content) + for language, translation in manager.get_translations(): + if language == ILanguage(self.es_content).get_language(): + self.assertEqual(translation, self.es_content) + + def test_calling_endpoint_without_id_gives_400(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_an_already_translated_content_gives_400(self): + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + 'id': self.es_content.absolute_url() + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_inexistent_content_gives_400(self): + response = requests.post( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + 'id': 'http://this-content-does-not-exist', + }, + ) + self.assertEqual(400, response.status_code) + +@unittest.skipUnless(PAM_INSTALLED, 'plone.app.multilingual is installed by default only in Plone 5') +class TestUnLinkContentTranslations(unittest.TestCase): + layer = PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + alsoProvides(self.layer['request'], IPloneAppMultilingualInstalled) + login(self.portal, SITE_OWNER_NAME) + self.en_content = createContentInContainer( + self.portal['en'], 'Document', title='Test document') + self.es_content = createContentInContainer( + self.portal['es'], 'Document', title='Test document') + ITranslationManager(self.en_content).register_translation( + 'es', self.es_content) + transaction.commit() + + def test_translation_unlinking_succeeds(self): + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "language": "es", + }, + ) + self.assertEqual(204, response.status_code) + transaction.begin() + manager = ITranslationManager(self.en_content) + self.assertNotIn( + ILanguage(self.es_content).get_language(), + manager.get_translations().keys()) + + def test_calling_endpoint_without_language_gives_400(self): + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + }, + ) + self.assertEqual(400, response.status_code) + + def test_calling_with_an_untranslated_content_gives_400(self): + ITranslationManager(self.en_content).remove_translation("es") + transaction.commit() + response = requests.delete( + '{}/@translations'.format(self.en_content.absolute_url()), + headers={'Accept': 'application/json'}, + auth=(SITE_OWNER_NAME, SITE_OWNER_PASSWORD), + json={ + "language": "es", + }, + ) + self.assertEqual(400, response.status_code) From 6b53e6191a80430bdf30e097ff14b70c289680f1 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 9 Oct 2017 10:03:55 +0200 Subject: [PATCH 5/7] properly allign closing brackets --- src/plone/restapi/services/multilingual/pam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plone/restapi/services/multilingual/pam.py b/src/plone/restapi/services/multilingual/pam.py index bb8030c7bf..618c837ad6 100644 --- a/src/plone/restapi/services/multilingual/pam.py +++ b/src/plone/restapi/services/multilingual/pam.py @@ -24,7 +24,7 @@ def reply(self): info['translations'].append({ '@id': translation.absolute_url(), 'language': language, - }) + }) return info From 6b377bd9b000d28f965fff11073645b3098790dc Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Mon, 9 Oct 2017 10:04:19 +0200 Subject: [PATCH 6/7] note that this is an only-plone5 feature --- docs/source/translations.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/translations.rst b/docs/source/translations.rst index 3817ee92e0..c81731f70a 100644 --- a/docs/source/translations.rst +++ b/docs/source/translations.rst @@ -1,6 +1,10 @@ Translations ============ +.. note:: + This is only available on Plone 5. + + Since Plone 5 the product `plone.app.multilingual`_ is included in the base Plone installation although it is not enabled by default. plone.restapi provides a `@translations` endpoint to handle the translation information @@ -51,10 +55,6 @@ endpoint of the content item and provide the language code you want to unlink.: .. literalinclude:: _json/translations_delete.resp :language: http -.. note:: - The `@translations` endpoint works also when using `Products.LinguaPlone`_ - in Plone 4.3.x - .. _`plone.app.multilingual`: https://pypi.python.org/pypi/plone.app.multilingual .. _`Products.LinguaPlone`: https://pypi.python.org/pypi/Products.LinguaPlone. From 8ad85bee9b81d22a060f56a7ce3da8f8159cd261 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Thu, 26 Oct 2017 18:22:17 +0200 Subject: [PATCH 7/7] improve documentation explaining multilingual capabilities --- docs/source/translations.rst | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/source/translations.rst b/docs/source/translations.rst index c81731f70a..4544c6ca79 100644 --- a/docs/source/translations.rst +++ b/docs/source/translations.rst @@ -4,11 +4,20 @@ Translations .. note:: This is only available on Plone 5. - Since Plone 5 the product `plone.app.multilingual`_ is included in the base -Plone installation although it is not enabled by default. plone.restapi -provides a `@translations` endpoint to handle the translation information -of the content objects. +Plone installation although it is not enabled by default. + +Multilingualism in Plone not only allows the managers of the site to configure +the site interface texts to be in one language or another (such as the +configuration menus, error messages, information messages or other static +text) but also to configure Plone to handle multilingual content. To achieve +that it provides the user interface for managing content translations. + +You can get additional information about the multilingual capabilities of Plone +in the `documentation`_. + +In connection with that capabilities, plone.restapi provides a `@translations` +endpoint to handle the translation information of the content objects. Once we have installed `plone.app.multilingual`_ and enabled more than one language we can link two content-items of different languages to be the @@ -58,3 +67,4 @@ endpoint of the content item and provide the language code you want to unlink.: .. _`plone.app.multilingual`: https://pypi.python.org/pypi/plone.app.multilingual .. _`Products.LinguaPlone`: https://pypi.python.org/pypi/Products.LinguaPlone. +.. _`documentation`: https://docs.plone.org/develop/plone/i18n/translating_content.html \ No newline at end of file