From b1b69360a334b947881679e044fdc8c14fead658 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 14 Apr 2021 09:56:00 +0200 Subject: [PATCH] Add contrib simple translation (#6528) --- docs/advanced_topics/i18n.rst | 26 +- docs/reference/contrib/index.rst | 7 + docs/reference/contrib/simple_translation.rst | 31 ++ .../contrib/simple_translation/__init__.py | 3 + wagtail/contrib/simple_translation/apps.py | 8 + wagtail/contrib/simple_translation/forms.py | 48 ++++ .../migrations/0001_initial.py | 24 ++ .../simple_translation/migrations/__init__.py | 0 wagtail/contrib/simple_translation/models.py | 16 ++ .../admin/submit_translation.html | 48 ++++ .../simple_translation/tests/__init__.py | 0 .../simple_translation/tests/test_forms.py | 86 ++++++ .../tests/test_migrations.py | 18 ++ .../simple_translation/tests/test_views.py | 265 ++++++++++++++++++ .../tests/test_wagtail_hooks.py | 102 +++++++ wagtail/contrib/simple_translation/views.py | 145 ++++++++++ .../simple_translation/wagtail_hooks.py | 78 ++++++ wagtail/tests/settings.py | 1 + 18 files changed, 899 insertions(+), 7 deletions(-) create mode 100644 docs/reference/contrib/simple_translation.rst create mode 100644 wagtail/contrib/simple_translation/__init__.py create mode 100644 wagtail/contrib/simple_translation/apps.py create mode 100644 wagtail/contrib/simple_translation/forms.py create mode 100644 wagtail/contrib/simple_translation/migrations/0001_initial.py create mode 100644 wagtail/contrib/simple_translation/migrations/__init__.py create mode 100644 wagtail/contrib/simple_translation/models.py create mode 100644 wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html create mode 100644 wagtail/contrib/simple_translation/tests/__init__.py create mode 100644 wagtail/contrib/simple_translation/tests/test_forms.py create mode 100644 wagtail/contrib/simple_translation/tests/test_migrations.py create mode 100644 wagtail/contrib/simple_translation/tests/test_views.py create mode 100644 wagtail/contrib/simple_translation/tests/test_wagtail_hooks.py create mode 100644 wagtail/contrib/simple_translation/views.py create mode 100644 wagtail/contrib/simple_translation/wagtail_hooks.py diff --git a/docs/advanced_topics/i18n.rst b/docs/advanced_topics/i18n.rst index 905a4aeaa5..97f0952920 100644 --- a/docs/advanced_topics/i18n.rst +++ b/docs/advanced_topics/i18n.rst @@ -21,10 +21,9 @@ This document describes how to configure Wagtail for authoring content in multiple languages. .. note:: - Wagtail provides the infrastructure for creating and serving content in multiple languages, - but does not itself provide an admin interface for managing translations of the same content - across different languages. For this, the `wagtail-localize `_ - app must be installed separately. + Wagtail provides the infrastructure for creating and serving content in multiple languages. + There are two options for managing translations across different languages in the admin interface: + :ref:`wagtail.contrib.simple_translation` or the more advanced `wagtail-localize `_ (third-party package). This document only covers the internationalisation of content managed by Wagtail. For information on how to translate static content in template files, JavaScript @@ -611,9 +610,22 @@ data migration). Translation workflow -------------------- -As mentioned at the beginning, Wagtail does not supply any built-in user interface -or external integration that provides a translation workflow. This has been left -for third-party packages to solve. +As mentioned at the beginning, Wagtail does supply ``wagtail.contrib.simple_translation``. + +The simple_translation module provides a user interface that allows users to copy pages and translatable snippets into another language. + +- Copies are created in the source language (not translated) +- Copies of pages are in draft status + +Content editors need to translate the content and publish the pages. + +To enable add ``"wagtail.contrib.simple_translation"`` to ``INSTALLED_APPS`` +and run ``python manage.py migrate`` to create the ``submit_translation`` permissions. +In the Wagtail admin, go to settings and give some users or groups the "Can submit translations" permission. + +.. note:: + Simple Translation is optional. It can be switched out by third-party packages. Like the more advanced `wagtail-localize `_. + Wagtail Localize ^^^^^^^^^^^^^^^^ diff --git a/docs/reference/contrib/index.rst b/docs/reference/contrib/index.rst index dd26bbeca2..8f432f09e8 100644 --- a/docs/reference/contrib/index.rst +++ b/docs/reference/contrib/index.rst @@ -15,6 +15,7 @@ Wagtail ships with a variety of extra optional modules. modeladmin/index postgres_search searchpromotions + simple_translation table_block redirects legacy_richtext @@ -63,6 +64,12 @@ A module allowing for more customisable representation and management of custom A module for managing "Promoted Search Results" +:doc:`simple_translation` +------------------------- + +A module for copying translatables (pages and snippets) to another language. + + :doc:`table_block` ----------------------- diff --git a/docs/reference/contrib/simple_translation.rst b/docs/reference/contrib/simple_translation.rst new file mode 100644 index 0000000000..56ebb94ffb --- /dev/null +++ b/docs/reference/contrib/simple_translation.rst @@ -0,0 +1,31 @@ +.. _simple_translation: + +Simple translation +================== + +The simple_translation module provides a user interface that allows users to copy pages and translatable snippets into another language. + +- Copies are created in the source language (not translated) +- Copies of pages are in draft status + +Content editors need to translate the content and publish the pages. + +.. note:: + Simple Translation is optional. It can be switched out by third-party packages. Like the more advanced `wagtail-localize `_. + + +Basic configuration +~~~~~~~~~~~~~~~~~~~ + +Add ``"wagtail.contrib.simple_translation"`` to INSTALLED_APPS in your settings file: + +.. code-block:: python + + INSTALLED_APPS = [ + ... + "wagtail.contrib.simple_translation", + ] + +Run ``python manage.py migrate`` to create the necessary permissions. + +In the Wagtail admin, go to settings and give some users or groups the "Can submit translations" permission. diff --git a/wagtail/contrib/simple_translation/__init__.py b/wagtail/contrib/simple_translation/__init__.py new file mode 100644 index 0000000000..d5a1a6aa69 --- /dev/null +++ b/wagtail/contrib/simple_translation/__init__.py @@ -0,0 +1,3 @@ +default_app_config = ( + "wagtail.contrib.simple_translation.apps.SimpleTranslationAppConfig" +) diff --git a/wagtail/contrib/simple_translation/apps.py b/wagtail/contrib/simple_translation/apps.py new file mode 100644 index 0000000000..9066341200 --- /dev/null +++ b/wagtail/contrib/simple_translation/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class SimpleTranslationAppConfig(AppConfig): + name = "wagtail.contrib.simple_translation" + label = "simple_translation" + verbose_name = _("Wagtail simple translation") diff --git a/wagtail/contrib/simple_translation/forms.py b/wagtail/contrib/simple_translation/forms.py new file mode 100644 index 0000000000..6f7e45c32a --- /dev/null +++ b/wagtail/contrib/simple_translation/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.utils.translation import gettext_lazy, ngettext + +from wagtail.core.models import Locale, Page + + +class SubmitTranslationForm(forms.Form): + # Note: We don't actually use select_all in Python, it is just the + # easiest way to add the widget to the form. It's controlled in JS. + select_all = forms.BooleanField(label=gettext_lazy("Select all"), required=False) + locales = forms.ModelMultipleChoiceField( + label=gettext_lazy("Locales"), + queryset=Locale.objects.none(), + widget=forms.CheckboxSelectMultiple, + ) + include_subtree = forms.BooleanField( + required=False, help_text=gettext_lazy("All child pages will be created.") + ) + + def __init__(self, instance, *args, **kwargs): + super().__init__(*args, **kwargs) + + hide_include_subtree = True + + if isinstance(instance, Page): + descendant_count = instance.get_descendants().count() + + if descendant_count > 0: + hide_include_subtree = False + self.fields["include_subtree"].label = ngettext( + "Include subtree ({} page)", + "Include subtree ({} pages)", + descendant_count, + ).format(descendant_count) + + if hide_include_subtree: + self.fields["include_subtree"].widget = forms.HiddenInput() + + self.fields["locales"].queryset = Locale.objects.exclude( + id__in=instance.get_translations(inclusive=True).values_list( + "locale_id", flat=True + ) + ) + + # Using len() instead of count() here as we're going to evaluate this queryset + # anyway and it gets cached so it'll only have one query in the end. + if len(self.fields["locales"].queryset) < 2: + self.fields["select_all"].widget = forms.HiddenInput() diff --git a/wagtail/contrib/simple_translation/migrations/0001_initial.py b/wagtail/contrib/simple_translation/migrations/0001_initial.py new file mode 100644 index 0000000000..92589cdb97 --- /dev/null +++ b/wagtail/contrib/simple_translation/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.16 on 2021-04-12 20:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SimpleTranslation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'permissions': [('submit_translation', 'Can submit translations')], + 'default_permissions': [], + }, + ), + ] diff --git a/wagtail/contrib/simple_translation/migrations/__init__.py b/wagtail/contrib/simple_translation/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wagtail/contrib/simple_translation/models.py b/wagtail/contrib/simple_translation/models.py new file mode 100644 index 0000000000..b642d4b3cd --- /dev/null +++ b/wagtail/contrib/simple_translation/models.py @@ -0,0 +1,16 @@ +from django.db.models import Model + + +class SimpleTranslation(Model): + """ + SimpleTranslation, dummy model to create the `submit_translation` permission. + + We need this model to be concrete or the following management commands will misbehave: + - `remove_stale_contenttypes`, will drop the perm + - `dump_data`, will complain about the missing table + """ + class Meta: + default_permissions = [] + permissions = [ + ('submit_translation', "Can submit translations"), + ] diff --git a/wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html b/wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html new file mode 100644 index 0000000000..b56947aaec --- /dev/null +++ b/wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html @@ -0,0 +1,48 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n wagtailadmin_tags %} +{% block titletag %}{{ view.get_title }} {{ view.get_subtitle }}{% endblock %} + +{% block content %} + {% include "wagtailadmin/shared/header.html" with title=view.get_title subtitle=view.get_subtitle icon="doc-empty-inverse" %} + +
+
+ {% csrf_token %} + + {% if next_url %} + + {% endif %} + + {% for field in form.hidden_fields %}{{ field }}{% endfor %} + +
    + {% block visible_fields %} + {% for field in form.visible_fields %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endfor %} + {% endblock %} +
  • + +
  • +
+
+
+{% endblock %} + + +{% block extra_js %} + {{ block.super }} + + +{% endblock %} diff --git a/wagtail/contrib/simple_translation/tests/__init__.py b/wagtail/contrib/simple_translation/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wagtail/contrib/simple_translation/tests/test_forms.py b/wagtail/contrib/simple_translation/tests/test_forms.py new file mode 100644 index 0000000000..305f69eed6 --- /dev/null +++ b/wagtail/contrib/simple_translation/tests/test_forms.py @@ -0,0 +1,86 @@ +from django.forms import CheckboxInput, HiddenInput +from django.test import TestCase, override_settings + +from wagtail.contrib.simple_translation.forms import SubmitTranslationForm +from wagtail.core.models import Locale, Page +from wagtail.tests.i18n.models import TestPage +from wagtail.tests.utils import WagtailTestUtils + + +@override_settings( + LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], + WAGTAIL_CONTENT_LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], +) +class TestSubmitPageTranslation(WagtailTestUtils, TestCase): + def setUp(self): + self.en_locale = Locale.objects.first() + self.fr_locale = Locale.objects.create(language_code="fr") + self.de_locale = Locale.objects.create(language_code="de") + + self.en_homepage = Page.objects.get(depth=2) + self.fr_homepage = self.en_homepage.copy_for_translation(self.fr_locale) + self.de_homepage = self.en_homepage.copy_for_translation(self.de_locale) + + self.en_blog_index = TestPage(title="Blog", slug="blog") + self.en_homepage.add_child(instance=self.en_blog_index) + + self.en_blog_post = TestPage(title="Blog post", slug="blog-post") + self.en_blog_index.add_child(instance=self.en_blog_post) + + def test_include_subtree(self): + form = SubmitTranslationForm(instance=self.en_blog_post) + self.assertIsInstance(form.fields["include_subtree"].widget, HiddenInput) + + form = SubmitTranslationForm(instance=self.en_blog_index) + self.assertIsInstance(form.fields["include_subtree"].widget, CheckboxInput) + self.assertEqual( + form.fields["include_subtree"].label, "Include subtree (1 page)" + ) + + form = SubmitTranslationForm(instance=self.en_homepage) + self.assertEqual( + form.fields["include_subtree"].label, "Include subtree (2 pages)" + ) + + def test_locales_queryset(self): + # Homepage is translated to all locales. + form = SubmitTranslationForm(instance=self.en_homepage) + self.assertEqual( + list( + form.fields["locales"].queryset.values_list("language_code", flat=True) + ), + [], + ) + # Blog index can be translated to `de` and `fr`. + form = SubmitTranslationForm(instance=self.en_blog_index) + self.assertEqual( + list( + form.fields["locales"].queryset.values_list("language_code", flat=True) + ), + ["de", "fr"], + ) + # Blog post can be translated to `de` and `fr`. + form = SubmitTranslationForm(instance=self.en_blog_post) + self.assertEqual( + list( + form.fields["locales"].queryset.values_list("language_code", flat=True) + ), + ["de", "fr"], + ) + + def test_select_all(self): + form = SubmitTranslationForm(instance=self.en_homepage) + # Homepage is translated to all locales. + self.assertIsInstance(form.fields["select_all"].widget, HiddenInput) + + form = SubmitTranslationForm(instance=self.en_blog_index) + # Blog post can be translated to `de` and `fr`. + self.assertIsInstance(form.fields["select_all"].widget, CheckboxInput) diff --git a/wagtail/contrib/simple_translation/tests/test_migrations.py b/wagtail/contrib/simple_translation/tests/test_migrations.py new file mode 100644 index 0000000000..124cf0eb23 --- /dev/null +++ b/wagtail/contrib/simple_translation/tests/test_migrations.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType + +from wagtail.tests.utils import TestCase + + +class TestMigrations(TestCase): + def test_content_type_exists(self): + self.assertTrue( + ContentType.objects.filter( + app_label="simple_translation", model="simpletranslation" + ).exists() + ) + + def test_permission_exists(self): + self.assertTrue( + Permission.objects.filter(codename="submit_translation").exists() + ) diff --git a/wagtail/contrib/simple_translation/tests/test_views.py b/wagtail/contrib/simple_translation/tests/test_views.py new file mode 100644 index 0000000000..79cc52a466 --- /dev/null +++ b/wagtail/contrib/simple_translation/tests/test_views.py @@ -0,0 +1,265 @@ +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.http import Http404 +from django.test import RequestFactory, override_settings +from django.urls import reverse +from django.utils.translation import gettext_lazy + +from wagtail.contrib.simple_translation.forms import SubmitTranslationForm +from wagtail.contrib.simple_translation.views import ( + SubmitPageTranslationView, SubmitSnippetTranslationView, SubmitTranslationView) +from wagtail.core.models import Locale, Page, ParentNotTranslatedError +from wagtail.tests.i18n.models import TestPage +from wagtail.tests.snippets.models import TranslatableSnippet +from wagtail.tests.utils import TestCase, WagtailTestUtils + + +@override_settings( + LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], + WAGTAIL_CONTENT_LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], +) +class TestSubmitTranslationView(WagtailTestUtils, TestCase): + def setUp(self): + self.en_locale = Locale.objects.first() + self.fr_locale = Locale.objects.create(language_code="fr") + self.de_locale = Locale.objects.create(language_code="de") + self.en_homepage = Page.objects.get(depth=2) + self.factory = RequestFactory() + + def test_template_name(self): + self.assertEqual( + SubmitTranslationView.template_name, + "simple_translation/admin/submit_translation.html", + ) + + def test_title(self): + self.assertEqual(SubmitTranslationView().title, gettext_lazy("Translate")) + self.assertEqual(SubmitTranslationView().get_title(), gettext_lazy("Translate")) + + def test_subtitle(self): + view = SubmitTranslationView() + view.object = self.en_homepage + self.assertEqual(view.get_subtitle(), str(self.en_homepage)) + + def test_get_form(self): + view = SubmitTranslationView() + view.request = self.factory.get("/path/does/not/matter/") + view.object = self.en_homepage + form = view.get_form() + self.assertIsInstance(form, SubmitTranslationForm) + + def test_get_success_url(self): + with self.assertRaises(NotImplementedError): + view = SubmitTranslationView() + view.object = self.en_homepage + view.get_success_url() + + def test_get_context_data(self, **kwargs): + view = SubmitTranslationView() + view.request = self.factory.get("/path/does/not/matter/") + view.object = self.en_homepage + context = view.get_context_data() + self.assertTrue("form" in context.keys()) + self.assertIsInstance(context["form"], SubmitTranslationForm) + + def test_dispatch_as_anon(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_homepage.id,) + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, f"/admin/login/?next={url}") + + def test_dispatch_as_moderator(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_homepage.id,) + ) + user = self.login() + group = Group.objects.get(name="Moderators") + user.groups.add(group) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_dispatch_as_user_with_perm(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_homepage.id,) + ) + user = self.login() + permission = Permission.objects.get(codename="submit_translation") + user.user_permissions.add(permission) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +@override_settings( + LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], + WAGTAIL_CONTENT_LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], +) +class TestSubmitPageTranslationView(WagtailTestUtils, TestCase): + def setUp(self): + self.en_locale = Locale.objects.first() + self.fr_locale = Locale.objects.create(language_code="fr") + self.de_locale = Locale.objects.create(language_code="de") + + self.en_homepage = Page.objects.get(depth=2) + self.fr_homepage = self.en_homepage.copy_for_translation(self.fr_locale) + self.de_homepage = self.en_homepage.copy_for_translation(self.de_locale) + + self.en_blog_index = TestPage(title="Blog", slug="blog") + self.en_homepage.add_child(instance=self.en_blog_index) + + self.en_blog_post = TestPage(title="Blog post", slug="blog-post") + self.en_blog_index.add_child(instance=self.en_blog_post) + + def test_title(self): + self.assertEqual(SubmitPageTranslationView.title, "Translate page") + + def test_get_subtitle(self): + view = SubmitPageTranslationView() + view.object = self.en_homepage + self.assertEqual(view.get_subtitle(), "Welcome to your new Wagtail site!") + + def test_submit_page_translation_view_test_get(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_blog_index.id,) + ) + self.login() + response = self.client.get(url) + assert isinstance(response.context["form"], SubmitTranslationForm) + + def test_submit_page_translation_view_test_post_invalid(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_blog_index.id,) + ) + self.login() + response = self.client.post(url, {}) + assert response.status_code == 200 + assert response.context["form"].errors == { + "locales": ["This field is required."] + } + + def test_submit_page_translation_view_test_post_single_locale(self): + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_blog_index.id,) + ) + de = Locale.objects.get(language_code="de").id + data = {"locales": [de], "include_subtree": True} + self.login() + response = self.client.post(url, data) + + assert response.status_code == 302 + assert response.url == f"/admin/pages/{self.en_blog_index.get_parent().id}/" + + response = self.client.get(response.url) # follow the redirect + assert [msg.message for msg in response.context["messages"]] == [ + "The page 'Blog' was successfully created in German" + ] + + def test_submit_page_translation_view_test_post_multiple_locales(self): + # Needs an extra page to hit recursive function + en_blog_post_sub = Page(title="Blog post sub", slug="blog-post-sub") + self.en_blog_post.add_child(instance=en_blog_post_sub) + + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_blog_post.id,) + ) + de = Locale.objects.get(language_code="de").id + fr = Locale.objects.get(language_code="fr").id + data = {"locales": [de, fr], "include_subtree": True} + self.login() + + with self.assertRaisesMessage(ParentNotTranslatedError, ""): + self.client.post(url, data) + + url = reverse( + "simple_translation:submit_page_translation", args=(self.en_blog_index.id,) + ) + response = self.client.post(url, data) + + assert response.status_code == 302 + assert response.url == f"/admin/pages/{self.en_blog_index.get_parent().id}/" + + response = self.client.get(response.url) # follow the redirect + assert [msg.message for msg in response.context["messages"]] == [ + "The page 'Blog' was successfully created in 2 locales" + ] + + +@override_settings( + LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], + WAGTAIL_CONTENT_LANGUAGES=[ + ("en", "English"), + ("fr", "French"), + ("de", "German"), + ], +) +class TestSubmitSnippetTranslationView(WagtailTestUtils, TestCase): + def setUp(self): + self.en_locale = Locale.objects.first() + self.fr_locale = Locale.objects.create(language_code="fr") + self.en_snippet = TranslatableSnippet(text="Hello world", locale=self.en_locale) + self.en_snippet.save() + + def test_get_title(self): + view = SubmitSnippetTranslationView() + view.object = self.en_snippet + self.assertEqual(view.get_title(), "Translate translatable snippet") + + def test_get_object(self): + view = SubmitSnippetTranslationView() + view.object = self.en_snippet + view.kwargs = { + "app_label": "some_app", + "model_name": "some_model", + "pk": 1, + } + with self.assertRaises(Http404): + view.get_object() + + content_type = ContentType.objects.get_for_model(self.en_snippet) + view.kwargs = { + "app_label": content_type.app_label, + "model_name": content_type.model, + "pk": str(self.en_snippet.pk), + } + self.assertEqual(view.get_object(), self.en_snippet) + + def test_get_success_url(self): + view = SubmitSnippetTranslationView() + view.object = self.en_snippet + view.kwargs = { + "app_label": "some_app", + "model_name": "some_model", + "pk": 99, + } + self.assertEqual( + view.get_success_url(), "/admin/snippets/some_app/some_model/99/" + ) + + def test_get_success_message(self): + view = SubmitSnippetTranslationView() + view.object = self.en_snippet + self.assertEqual( + view.get_success_message(self.fr_locale), + f"Successfully created French for translatable snippet 'TranslatableSnippet object ({self.en_snippet.id})'", + ) diff --git a/wagtail/contrib/simple_translation/tests/test_wagtail_hooks.py b/wagtail/contrib/simple_translation/tests/test_wagtail_hooks.py new file mode 100644 index 0000000000..114cc303c2 --- /dev/null +++ b/wagtail/contrib/simple_translation/tests/test_wagtail_hooks.py @@ -0,0 +1,102 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.test import TestCase +from django.urls import reverse + +from wagtail.admin import widgets as wagtailadmin_widgets +from wagtail.contrib.simple_translation.wagtail_hooks import ( + page_listing_more_buttons, register_submit_translation_permission) +from wagtail.core.models import Locale, Page +from wagtail.tests.i18n.models import TestPage +from wagtail.tests.utils import WagtailTestUtils + + +class Utils(WagtailTestUtils, TestCase): + def setUp(self): + self.en_locale = Locale.objects.first() + self.fr_locale = Locale.objects.create(language_code="fr") + self.de_locale = Locale.objects.create(language_code="de") + + self.en_homepage = Page.objects.get(depth=2) + self.fr_homepage = self.en_homepage.copy_for_translation(self.fr_locale) + self.de_homepage = self.en_homepage.copy_for_translation(self.de_locale) + + self.en_blog_index = TestPage(title="Blog", slug="blog") + self.en_homepage.add_child(instance=self.en_blog_index) + + self.en_blog_post = TestPage(title="Blog post", slug="blog-post") + self.en_blog_index.add_child(instance=self.en_blog_post) + + +class TestWagtailHooksURLs(TestCase): + def test_register_admin_urls_page(self): + self.assertEqual( + reverse("simple_translation:submit_page_translation", args=(1,)), + "/admin/translation/submit/page/1/", + ) + + def test_register_admin_urls_snippet(self): + app_label = "foo" + model_name = "bar" + pk = 1 + self.assertEqual( + reverse( + "simple_translation:submit_snippet_translation", + args=(app_label, model_name, pk), + ), + "/admin/translation/submit/snippet/foo/bar/1/", + ) + + +class TestWagtailHooksPermission(Utils): + def test_register_submit_translation_permission(self): + assert list( + register_submit_translation_permission().values_list("id", flat=True) + ) == [ + Permission.objects.get( + content_type__app_label="simple_translation", + codename="submit_translation", + ).id + ] + + +class TestWagtailHooksButtons(Utils): + class PagePerms: + def __init__(self, user): + self.user = user + + def test_page_listing_more_buttons(self): + # Root, no button + root_page = self.en_blog_index.get_root() + + if get_user_model().USERNAME_FIELD == 'email': + user = get_user_model().objects.create_user(email="jos@example.com") + else: + user = get_user_model().objects.create_user(username="jos") + assert list(page_listing_more_buttons(root_page, self.PagePerms(user))) == [] + + # No permissions, no button + home_page = self.en_homepage + assert list(page_listing_more_buttons(root_page, self.PagePerms(user))) == [] + + # Homepage is translated to all languages, no button + perm = Permission.objects.get(codename="submit_translation") + + if get_user_model().USERNAME_FIELD == 'email': + user = get_user_model().objects.create_user(email="henk@example.com") + else: + user = get_user_model().objects.create_user(username="henk") + + # New user, to prevent permission cache. + user.user_permissions.add(perm) + group = Group.objects.get(name="Editors") + user.groups.add(group) + page_perms = self.PagePerms(user) + assert list(page_listing_more_buttons(home_page, page_perms)) == [] + + # Page does not have translations yet... button! + blog_page = self.en_blog_post + assert isinstance( + list(page_listing_more_buttons(blog_page, page_perms))[0], + wagtailadmin_widgets.Button, + ) diff --git a/wagtail/contrib/simple_translation/views.py b/wagtail/contrib/simple_translation/views.py new file mode 100644 index 0000000000..3671deb422 --- /dev/null +++ b/wagtail/contrib/simple_translation/views.py @@ -0,0 +1,145 @@ +from django.contrib import messages +from django.contrib.admin.utils import unquote +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy +from django.views.generic import TemplateView +from django.views.generic.detail import SingleObjectMixin + +from wagtail.core.models import Page, TranslatableMixin +from wagtail.snippets.views.snippets import get_snippet_model_from_url_params + +from .forms import SubmitTranslationForm + + +class SubmitTranslationView(SingleObjectMixin, TemplateView): + template_name = "simple_translation/admin/submit_translation.html" + title = gettext_lazy("Translate") + + def get_title(self): + return self.title + + def get_subtitle(self): + return str(self.object) + + def get_form(self): + if self.request.method == "POST": + return SubmitTranslationForm(self.object, self.request.POST) + return SubmitTranslationForm(self.object) + + def get_success_url(self): + raise NotImplementedError + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update( + { + "form": self.get_form(), + } + ) + return context + + def post(self, request, **kwargs): # pragma: no mccabe + form = self.get_form() + + if form.is_valid(): + with transaction.atomic(): + for locale in form.cleaned_data["locales"]: + if isinstance(self.object, Page): + self.object.copy_for_translation(locale) + if form.cleaned_data["include_subtree"]: + + def _walk(current_page): + for child_page in current_page.get_children(): + child_page.copy_for_translation(locale) + + if child_page.numchild: + _walk(child_page) + + _walk(self.object) + else: + self.object.copy_for_translation( + locale + ).save() # pragma: no cover + + if len(form.cleaned_data["locales"]) == 1: + locales = form.cleaned_data["locales"][0].get_display_name() + else: + # Note: always plural + locales = _("{} locales").format(len(form.cleaned_data["locales"])) + + messages.success(self.request, self.get_success_message(locales)) + + return redirect(self.get_success_url()) + + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + + def dispatch(self, request, *args, **kwargs): + if not request.user.has_perms(["simple_translation.submit_translation"]): + raise PermissionDenied + + self.object = self.get_object() + return super().dispatch(request, *args, **kwargs) + + +class SubmitPageTranslationView(SubmitTranslationView): + title = gettext_lazy("Translate page") + + def get_subtitle(self): + return self.object.get_admin_display_title() + + def get_object(self): + page = get_object_or_404(Page, id=self.kwargs["page_id"]).specific + + # Can't translate the root page + if page.is_root(): + raise Http404 + + return page + + def get_success_url(self): + return reverse("wagtailadmin_explore", args=[self.get_object().get_parent().id]) + + def get_success_message(self, locales): + return _( + "The page '{page_title}' was successfully created in {locales}" + ).format(page_title=self.object.get_admin_display_title(), locales=locales) + + +class SubmitSnippetTranslationView(SubmitTranslationView): + def get_title(self): + return _("Translate {model_name}").format( + model_name=self.object._meta.verbose_name + ) + + def get_object(self): + model = get_snippet_model_from_url_params( + self.kwargs["app_label"], self.kwargs["model_name"] + ) + + if not issubclass(model, TranslatableMixin): + raise Http404 + + return get_object_or_404(model, pk=unquote(self.kwargs["pk"])) + + def get_success_url(self): + return reverse( + "wagtailsnippets:edit", + args=[ + self.kwargs["app_label"], + self.kwargs["model_name"], + self.kwargs["pk"], + ], + ) + + def get_success_message(self, locales): + return _("Successfully created {locales} for {model_name} '{object}'").format( + model_name=self.object._meta.verbose_name, + object=str(self.object), + locales=locales, + ) diff --git a/wagtail/contrib/simple_translation/wagtail_hooks.py b/wagtail/contrib/simple_translation/wagtail_hooks.py new file mode 100644 index 0000000000..38dfd7f70a --- /dev/null +++ b/wagtail/contrib/simple_translation/wagtail_hooks.py @@ -0,0 +1,78 @@ +from django.contrib.admin.utils import quote +from django.contrib.auth.models import Permission +from django.urls import include, path, reverse +from django.utils.translation import gettext as _ + +from wagtail.admin import widgets as wagtailadmin_widgets +from wagtail.core import hooks +from wagtail.core.models import Locale, TranslatableMixin +from wagtail.snippets.widgets import SnippetListingButton + +from .views import SubmitPageTranslationView, SubmitSnippetTranslationView + + +@hooks.register("register_admin_urls") +def register_admin_urls(): + urls = [ + path( + "submit/page//", + SubmitPageTranslationView.as_view(), + name="submit_page_translation", + ), + path( + "submit/snippet////", + SubmitSnippetTranslationView.as_view(), + name="submit_snippet_translation", + ), + ] + + return [ + path( + "translation/", + include( + (urls, "simple_translation"), + namespace="simple_translation", + ), + ) + ] + + +@hooks.register("register_permissions") +def register_submit_translation_permission(): + return Permission.objects.filter(content_type__app_label="simple_translation", codename="submit_translation") + + +@hooks.register("register_page_listing_more_buttons") +def page_listing_more_buttons(page, page_perms, is_parent=False, next_url=None): + if page_perms.user.has_perm("simple_translation.submit_translation") and not page.is_root(): + # If there's at least one locale that we haven't translated into yet, show "Translate this page" button + has_locale_to_translate_to = Locale.objects.exclude( + id__in=page.get_translations(inclusive=True).values_list("locale_id", flat=True) + ).exists() + + if has_locale_to_translate_to: + url = reverse("simple_translation:submit_page_translation", args=[page.id]) + yield wagtailadmin_widgets.Button(_("Translate"), url, priority=60) + + +@hooks.register("register_snippet_listing_buttons") +def register_snippet_listing_buttons(snippet, user, next_url=None): + model = type(snippet) + + if issubclass(model, TranslatableMixin) and user.has_perm("simple_translation.submit_translation"): + # If there's at least one locale that we haven't translated into yet, show "Translate" button + has_locale_to_translate_to = Locale.objects.exclude( + id__in=snippet.get_translations(inclusive=True).values_list("locale_id", flat=True) + ).exists() + + if has_locale_to_translate_to: + url = reverse( + "simple_translation:submit_snippet_translation", + args=[model._meta.app_label, model._meta.model_name, quote(snippet.pk)], + ) + yield SnippetListingButton( + _("Translate"), + url, + attrs={"aria-label": _("Translate '%(title)s'") % {"title": str(snippet)}}, + priority=100, + ) diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index d0df9d3de8..58079ece2e 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -121,6 +121,7 @@ INSTALLED_APPS = [ 'wagtail.tests.search', 'wagtail.tests.modeladmintest', 'wagtail.tests.i18n', + 'wagtail.contrib.simple_translation', 'wagtail.contrib.styleguide', 'wagtail.contrib.routable_page', 'wagtail.contrib.frontend_cache',