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] 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..4544c6ca79 --- /dev/null +++ b/docs/source/translations.rst @@ -0,0 +1,70 @@ +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. + +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 +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 + + +.. _`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 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..618c837ad6 --- /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..a4d062f6f7 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 @@ -31,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(): @@ -88,6 +97,52 @@ 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') + 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) + 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_documentation.py b/src/plone/restapi/tests/test_documentation.py index b8183c17d5..d152d07073 100644 --- a/src/plone/restapi/tests/test_documentation.py +++ b/src/plone/restapi/tests/test_documentation.py @@ -8,20 +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 PAM_INSTALLED from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING -from plone.restapi.testing import RelativeSession +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 @@ -35,6 +38,10 @@ import transaction import unittest +if PAM_INSTALLED: + from plone.app.multilingual.interfaces import ITranslationManager + + TUS_HEADERS = [ 'upload-offset', @@ -1225,3 +1232,82 @@ def test_controlpanels_get_item(self): '/@controlpanels/editing' ) save_request_and_response_for_docs('controlpanels_get_item', response) + + +@unittest.skipUnless(PAM_INSTALLED, 'plone.app.multilingual is installed by default only in Plone 5') +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 = 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 new file mode 100644 index 0000000000..09992676b1 --- /dev/null +++ b/src/plone/restapi/tests/test_translations.py @@ -0,0 +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 + + + +@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)