Skip to content

Commit

Permalink
Synchronise content between locales using alias pages
Browse files Browse the repository at this point in the history
  • Loading branch information
kaedroho committed Nov 4, 2020
1 parent 04a310c commit d06db66
Show file tree
Hide file tree
Showing 12 changed files with 506 additions and 5 deletions.
Empty file.
Empty file.
13 changes: 13 additions & 0 deletions wagtail_localize/management/commands/sync_locale_trees.py
@@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand

from wagtail_localize.models import LocaleSynchronization
from wagtail_localize.synctree import PageIndex


class Command(BaseCommand):
help = "Synchronises the structure of all locale page trees so they contain the same pages. Creates alias pages where necessary."

def handle(self, **options):
page_index = PageIndex.from_database().sort_by_tree_position()
for locale_sync in LocaleSynchronization.objects.all():
locale_sync.sync_trees(page_index=page_index)
23 changes: 23 additions & 0 deletions wagtail_localize/migrations/0012_localesynchronization.py
@@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2020-11-04 20:25

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('wagtailcore', '0059_apply_collection_ordering'),
('wagtail_localize', '0011_segmentoverride'),
]

operations = [
migrations.CreateModel(
name='LocaleSynchronization',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('locale', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailcore.locale')),
('sync_from', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailcore.locale')),
],
),
]
23 changes: 22 additions & 1 deletion wagtail_localize/models.py
Expand Up @@ -20,7 +20,8 @@
OuterRef,
Q
)
from django.db.models.signals import post_delete
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.text import capfirst, slugify
Expand All @@ -34,6 +35,7 @@
from wagtail.core.utils import find_available_slug

from .fields import copy_synchronised_fields
from .locales.components import register_locale_component
from .segments import StringSegmentValue, TemplateSegmentValue, RelatedObjectSegmentValue, OverridableSegmentValue
from .segments.extract import extract_segments
from .segments.ingest import ingest_segments
Expand Down Expand Up @@ -500,6 +502,10 @@ def create_or_update_translation(self, locale, user=None, publish=True, copy_par
ingest_segments(original, translation, self.locale, locale, segments)

if isinstance(translation, Page):
# Convert the page into a regular page
# TODO: Audit logging, etc
translation.alias_of_id = None

# Make sure the slug is valid
translation.slug = find_available_slug(translation.get_parent(), slugify(translation.slug), ignore_page_id=translation.id)
translation.save()
Expand Down Expand Up @@ -1345,3 +1351,18 @@ def disable_translation_on_delete(instance, **kwargs):
def register_post_delete_signal_handlers():
for model in get_translatable_models():
post_delete.connect(disable_translation_on_delete, sender=model)


@register_locale_component
class LocaleSynchronization(models.Model):
locale = models.OneToOneField('wagtailcore.Locale', on_delete=models.CASCADE, related_name='+')
sync_from = models.ForeignKey('wagtailcore.Locale', on_delete=models.CASCADE, related_name='+')

def sync_trees(self, *, page_index=None):
from .synctree import synchronize_tree
synchronize_tree(self.sync_from, self.locale, page_index=page_index)


@receiver(post_save, sender=LocaleSynchronization)
def sync_trees_on_locale_sync_save(instance, **kwargs):
instance.sync_trees()
214 changes: 214 additions & 0 deletions wagtail_localize/synctree.py
@@ -0,0 +1,214 @@
from collections import defaultdict

from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.functional import cached_property
from wagtail.core.models import Page, Locale


class PageIndex:
"""
An in-memory index of pages to remove the need to query the database.
Each entry in the index is a unique page by translation key, so a page
that has been translated into different languages appears only once.
"""

# Note: This has been designed to be as memory-efficient as possible, but it
# hasn't been tested on a very large site yet.

class Entry:
"""
Represents a page in the index.
"""

__slots__ = [
"content_type",
"translation_key",
"source_locale",
"parent_translation_key",
"locales",
"aliased_locales",
]

def __init__(
self,
content_type,
translation_key,
source_locale,
parent_translation_key,
locales,
aliased_locales,
):
self.content_type = content_type
self.translation_key = translation_key
self.source_locale = source_locale
self.parent_translation_key = parent_translation_key
self.locales = locales
self.aliased_locales = aliased_locales

REQUIRED_PAGE_FIELDS = [
"content_type",
"translation_key",
"locale",
"path",
"depth",
"last_published_at",
"latest_revision_created_at",
"live",
]

@classmethod
def from_page_instance(cls, page):
"""
Initialises an Entry from the given page instance.
"""
# Get parent, but only if the parent is not the root page. We consider the
# homepage of each langauge tree to be the roots
if page.depth > 2:
parent_page = page.get_parent()
else:
parent_page = None

return cls(
page.content_type,
page.translation_key,
page.locale,
parent_page.translation_key if parent_page else None,
list(
Page.objects.filter(
translation_key=page.translation_key,
alias_of__isnull=True,
).values_list("locale", flat=True)
),
list(
Page.objects.filter(
translation_key=page.translation_key,
alias_of__isnull=False,
).values_list("locale", flat=True)
),
)

def __init__(self, pages):
self.pages = pages

@cached_property
def by_translation_key(self):
return {page.translation_key: page for page in self.pages}

@cached_property
def by_parent_translation_key(self):
by_parent_translation_key = defaultdict(list)
for page in self.pages:
by_parent_translation_key[page.parent_translation_key].append(page)

return dict(by_parent_translation_key.items())

def sort_by_tree_position(self):
"""
Returns a new index with the pages sorted in depth-first-search order
using their parent in their respective source locale.
"""
remaining_pages = set(page.translation_key for page in self.pages)

new_pages = []

def _walk(translation_key):
for page in self.by_parent_translation_key.get(translation_key, []):
if page.translation_key not in remaining_pages:
continue

remaining_pages.remove(page.translation_key)
new_pages.append(page)
_walk(page.translation_key)

_walk(None)

if remaining_pages:
print("Warning: {} orphaned pages!".format(len(remaining_pages)))

return PageIndex(new_pages)

def not_translated_into(self, locale):
"""
Returns an index of pages that are not translated into the specified locale.
This includes pages that have and don't have a placeholder
"""
pages = [page for page in self.pages if locale.id not in page.locales]

return PageIndex(pages)

def __iter__(self):
return iter(self.pages)

@classmethod
def from_database(cls):
"""
Populates the index from the database.
"""
pages = []

for page in Page.objects.filter(alias_of__isnull=True, depth__gt=1).only(
*PageIndex.Entry.REQUIRED_PAGE_FIELDS
):
pages.append(PageIndex.Entry.from_page_instance(page))

return PageIndex(pages)


def synchronize_tree(source_locale, target_locale, *, page_index=None):
"""
Synchronises a locale tree with the other locales.
This creates any placeholders that don't exist yet, updates placeholders where their
source has been changed and moves pages to match the structure of other trees
"""
# Build a page index
if not page_index:
page_index = PageIndex.from_database().sort_by_tree_position()

# Find pages that are not translated for this locale
# This includes locales that have a placeholder, it only excludes locales that have an actual translation
pages_not_in_locale = page_index.not_translated_into(target_locale)

for page in pages_not_in_locale:
# Skip pages that do not exist in the source
if source_locale.id not in page.locales and source_locale.id not in page.aliased_locales:
continue

# Fetch source from database
model = page.content_type.model_class()
source_page = model.objects.get(
translation_key=page.translation_key, locale=source_locale
)

if target_locale.id not in page.aliased_locales:
source_page.copy_for_translation(
target_locale, copy_parents=True, alias=True
)


@receiver(post_save)
def on_page_saved(sender, instance, **kwargs):
if not issubclass(sender, Page):
return

# We only care about creations
if not kwargs['created']:
return

# Check if the source tree needs to be synchronised into any other trees
from .models import LocaleSynchronization
locales_to_sync_to = Locale.objects.filter(
id__in=(
LocaleSynchronization.objects
.filter(sync_from_id=instance.locale_id)
.values_list("locale_id", flat=True)
)
)

# Create aliases in all those locales
for locale in locales_to_sync_to:
instance.copy_for_translation(
locale, copy_parents=True, alias=True
)
@@ -0,0 +1,17 @@
{% extends "wagtailadmin/pages/edit.html" %}
{% load i18n %}

{% block form %}
<div class="nice-padding" style="padding-top: 20px;">
{% url 'wagtailadmin_pages:edit' page.alias_of_id as edit_original_page_url %}
<p>
{% blocktrans with edit_original_page_url=edit_original_page_url original_locale=page.alias_of.locale.get_display_name %}
This page hasn't been translated yet. It is mirroring the <a href="{{ edit_original_page_url }}" target="_blank">{{ original_locale }} page.</a>.
{% endblocktrans %}
</p>

<p>
<a class="button button-secondary" href="{% url 'wagtail_localize:submit_page_translation' page.alias_of_id %}?select_locale={{ page.locale.language_code }}&next={% url 'wagtailadmin_pages:edit' page.id %}">{% trans "Translate this page" %}</a>
</p>
</div>
{% endblock %}
2 changes: 1 addition & 1 deletion wagtail_localize/test/settings.py
Expand Up @@ -164,7 +164,7 @@

WAGTAIL_I18N_ENABLED = True

LANGUAGES = WAGTAIL_CONTENT_LANGUAGES = [("en", "English"), ("fr", "French"), ("es", "Spanish")]
LANGUAGES = WAGTAIL_CONTENT_LANGUAGES = [("en", "English"), ("fr", "French"), ("fr-CA", "French (Canada)"), ("es", "Spanish")]

WAGTAILLOCALIZE_MACHINE_TRANSLATOR = {
'CLASS': 'wagtail_localize.machine_translators.dummy.DummyTranslator',
Expand Down

0 comments on commit d06db66

Please sign in to comment.