# coding: utf-8 import copy import logging import types from django.core.exceptions import ValidationError, FieldDoesNotExist from django.core.urlresolvers import reverse from django.db import transaction, connection from django.http import Http404 from django.utils.translation import trans_real from django.utils.translation import ugettext_lazy as _ from modeltranslation import settings as mt_settings from modeltranslation.translator import translator, NotRegistered from modeltranslation.utils import build_localized_fieldname, get_language from wagtail.contrib.settings.models import BaseSetting from wagtail.contrib.settings.views import get_setting_edit_handler from wagtail.contrib.wagtailroutablepage.models import RoutablePageMixin from wagtail.wagtailadmin.edit_handlers import FieldPanel, \ MultiFieldPanel, FieldRowPanel, InlinePanel, StreamFieldPanel, RichTextFieldPanel from wagtail.wagtailcore.models import Page, Site from wagtail.wagtailcore.fields import StreamField, StreamValue from wagtail.wagtailcore.url_routing import RouteResult from wagtail.wagtailimages.edit_handlers import ImageChooserPanel from wagtail.wagtailsearch.index import SearchField from wagtail.wagtailsnippets.models import get_snippet_models from wagtail.wagtailsnippets.views.snippets import SNIPPET_EDIT_HANDLERS from wagtail_modeltranslation.settings import CUSTOM_SIMPLE_PANELS, CUSTOM_COMPOSED_PANELS, ORIGINAL_SLUG_LANGUAGE from wagtail_modeltranslation.utils import compare_class_tree_depth logger = logging.getLogger('wagtail.core') SIMPLE_PANEL_CLASSES = [FieldPanel, ImageChooserPanel, StreamFieldPanel, RichTextFieldPanel] + CUSTOM_SIMPLE_PANELS COMPOSED_PANEL_CLASSES = [MultiFieldPanel, FieldRowPanel] + CUSTOM_COMPOSED_PANELS class WagtailTranslator(object): _patched_models = [] def __init__(self, model): # Check if this class was already patched if model in WagtailTranslator._patched_models: return self.patched_model = model if issubclass(model, Page): self._patch_page_models(model) else: self._patch_other_models(model) WagtailTranslator._patched_models.append(model) def _patch_page_models(self, model): # PANEL PATCHING # Check if the model has a custom edit handler if hasattr(model, 'edit_handler'): tabs = model.edit_handler.children for tab in tabs: tab.children = self._patch_panels(tab.children) else: # If the page doesn't have an edit_handler we patch the panels that # wagtail uses by default if hasattr(model, 'content_panels'): model.content_panels = self._patch_panels(model.content_panels) if hasattr(model, 'promote_panels'): model.promote_panels = self._patch_panels(model.promote_panels) if hasattr(model, 'settings_panels'): model.settings_panels = self._patch_panels(model.settings_panels) # Clear the edit handler cached value, if it exists, so wagtail reconstructs # the edit_handler based on the patched panels model.get_edit_handler.cache_clear() # SEARCH FIELDS PATCHING translation_registered_fields = translator.get_options_for_model(model).fields for field in model.search_fields: # Check if the field is a SearchField and if it is one of the fields registered for translation if field.__class__ is SearchField and field.field_name in translation_registered_fields: # If it is we create a clone of the original SearchField to keep all the defined options # and replace its name by the translated one for language in mt_settings.AVAILABLE_LANGUAGES: translated_field = copy.deepcopy(field) translated_field.field_name = build_localized_fieldname(field.field_name, language) model.search_fields = list(model.search_fields) + [translated_field] # OVERRIDE FIELDS model_fields = model._meta.get_fields() for field in model_fields: if isinstance(field, StreamField) and field.name in translation_registered_fields: descriptor = getattr(model, field.name) _patch_stream_field_meaningful_value(descriptor) # SLUG FIELD PATCHING try: slug_field = model._meta.get_field('slug') _patch_pre_save(slug_field) except FieldDoesNotExist: pass # OVERRIDE PAGE METHODS model.move = _new_move model.set_url_path = _new_set_url_path model.route = _new_route model.get_site_root_paths = _new_get_site_root_paths model.relative_url = _new_relative_url model.url = _new_url _patch_clean(model) if not model.save.__name__.startswith('localized'): descriptor = LocalizedSaveDescriptor(model.save) setattr(model, 'save', descriptor) def _patch_other_models(self, model): if hasattr(model, 'edit_handler'): edit_handler = model.edit_handler for tab in edit_handler: tab.children = self._patch_panels(tab.children) elif hasattr(model, 'panels'): model.panels = self._patch_panels(model.panels) if model in get_snippet_models() and model in SNIPPET_EDIT_HANDLERS: del SNIPPET_EDIT_HANDLERS[model] else: get_setting_edit_handler.cache_clear() def _patch_panels(self, panels_list, related_model=None): """ Patching of the admin panels. If we're patching an InlinePanel panels we must provide the related model for that class, otherwise its used the model passed on init. """ patched_panels = [] current_patching_model = related_model or self.patched_model for panel in panels_list: if panel.__class__ in SIMPLE_PANEL_CLASSES: patched_panels += self._patch_simple_panel(current_patching_model, panel) elif panel.__class__ in COMPOSED_PANEL_CLASSES: patched_panels.append(self._patch_composed_panel(panel, related_model)) elif panel.__class__ == InlinePanel: patched_panels.append(self._patch_inline_panel(panel)) else: patched_panels.append(panel) return patched_panels def _patch_simple_panel(self, model, original_panel): panel_class = original_panel.__class__ translated_panels = [] translation_registered_fields = translator.get_options_for_model(model).fields # If the panel field is not registered for translation # the original one is returned if original_panel.field_name not in translation_registered_fields: return [original_panel] for language in mt_settings.AVAILABLE_LANGUAGES: original_field = model._meta.get_field(original_panel.field_name) localized_field_name = build_localized_fieldname(original_panel.field_name, language) # if the original field is required and the current language is the default one # this field's blank property is set to False if not original_field.blank and language == mt_settings.DEFAULT_LANGUAGE: localized_field = model._meta.get_field(localized_field_name) localized_field.blank = False localized_panel = panel_class(localized_field_name) # Pass the original panel extra attributes to the localized if hasattr(original_panel, 'classname'): localized_panel.classname = original_panel.classname if hasattr(original_panel, 'widget'): localized_panel.widget = original_panel.widget translated_panels.append(localized_panel) return translated_panels def _patch_composed_panel(self, original_panel, related_model=None): panel_class = original_panel.__class__ patched_children_panels = self._patch_panels(original_panel.children, related_model) localized_panel = panel_class(patched_children_panels) # Pass the original panel extra attributes to the localized if hasattr(original_panel, 'classname'): localized_panel.classname = original_panel.classname if hasattr(original_panel, 'heading'): localized_panel.heading = original_panel.heading return localized_panel def _patch_inline_panel(self, panel): # get the model relation through the panel relation_name which is the # inline model related_name relation = getattr(self.patched_model, panel.relation_name) try: related_model = relation.rel.related_model except AttributeError: # Django 1.8 related_model = relation.related.related_model # If the related model is not registered for translation there is nothing # for us to do try: translator.get_options_for_model(related_model) except NotRegistered: pass else: related_model.panels = self._patch_panels(getattr(related_model, 'panels', []), related_model) # The original panel is returned as only the related_model panels need to be # patched, leaving the original untouched return panel # Overridden Page methods adapted to the translated fields @transaction.atomic # only commit when all descendants are properly updated def _new_move(self, target, pos=None): """ Extension to the treebeard 'move' method to ensure that url_path is updated too. """ old_url_path = Page.objects.get(id=self.id).url_path super(Page, self).move(target, pos=pos) # treebeard's move method doesn't actually update the in-memory instance, so we need to work # with a freshly loaded one now # added .specific to use the most specific class so that url_paths are updated to all languages new_self = Page.objects.get(id=self.id).specific new_url_path = new_self.set_url_path(new_self.get_parent()) new_self.save() new_self._update_descendant_url_paths(old_url_path, new_url_path) # Log logger.info("Page moved: \"%s\" id=%d path=%s", self.title, self.id, new_url_path) def _new_set_url_path(self, parent): """ This method override populates url_path for each specified language. This way we can get different urls for each language, defined by page slug. """ for language in mt_settings.AVAILABLE_LANGUAGES: localized_slug_field = build_localized_fieldname('slug', language) default_localized_slug_field = build_localized_fieldname('slug', mt_settings.DEFAULT_LANGUAGE) localized_url_path_field = build_localized_fieldname('url_path', language) default_localized_url_path_field = build_localized_fieldname('url_path', mt_settings.DEFAULT_LANGUAGE) if parent: parent = parent.specific # Emulate the default behavior of django-modeltranslation to get the slug and url path # for the current language. If the value for the current language is invalid we get the one # for the default fallback language slug = getattr(self, localized_slug_field, None) or getattr(self, default_localized_slug_field, self.slug) parent_url_path = getattr(parent, localized_url_path_field, None) or \ getattr(parent, default_localized_url_path_field, parent.url_path) setattr(self, localized_url_path_field, parent_url_path + slug + '/') else: # a page without a parent is the tree root, # which always has a url_path of '/' setattr(self, localized_url_path_field, '/') return self.url_path def _new_route(self, request, path_components): """ Rewrite route method in order to handle languages fallbacks """ # copied from wagtail/contrib/wagtailroutablepage/models.py mixin ## # Override route when Page is also RoutablePage if isinstance(self, RoutablePageMixin): if self.live: try: path = '/' if path_components: path += '/'.join(path_components) + '/' view, args, kwargs = self.resolve_subpage(path) return RouteResult(self, args=(view, args, kwargs)) except Http404: pass if path_components: # request is for a child of this page child_slug = path_components[0] remaining_components = path_components[1:] subpages = self.get_children() for page in subpages: if page.specific.slug == child_slug: return page.specific.route(request, remaining_components) raise Http404 else: # request is for this very page if self.live: return RouteResult(self) else: raise Http404 @staticmethod def _new_get_site_root_paths(): """ Return a list of (root_path, root_url) tuples, most specific path first - used to translate url_paths into actual URLs with hostnames Same method as Site.get_site_root_paths() but without cache TODO: remake this method with cache and think of his integration in Site.get_site_root_paths() """ result = [ (site.id, site.root_page.specific.url_path, site.root_url) for site in Site.objects.select_related('root_page').order_by('-root_page__url_path') ] return result def _new_relative_url(self, current_site): """ Return the 'most appropriate' URL for this page taking into account the site we're currently on; a local URL if the site matches, or a fully qualified one otherwise. Return None if the page is not routable. Override for using custom get_site_root_paths() instead of Site.get_site_root_paths() """ for (id, root_path, root_url) in self.get_site_root_paths(): if self.url_path.startswith(root_path): return ('' if current_site.id == id else root_url) + reverse('wagtail_serve', args=(self.specific.url_path[len(root_path):],)) @property def _new_url(self): """ Return the 'most appropriate' URL for referring to this page from the pages we serve, within the Wagtail backend and actual website templates; this is the local URL (starting with '/') if we're only running a single site (i.e. we know that whatever the current page is being served from, this link will be on the same domain), and the full URL (with domain) if not. Return None if the page is not routable. Override for using custom get_site_root_paths() instead of Site.get_site_root_paths() """ root_paths = self.get_site_root_paths() for (id, root_path, root_url) in root_paths: if self.url_path.startswith(root_path): return ('' if len(root_paths) == 1 else root_url) + reverse( 'wagtail_serve', args=(self.url_path[len(root_path):],)) def _validate_slugs(page): """ Determine whether the given slug is available for use on a child page of parent_page. """ parent_page = page.get_parent() if parent_page is None: # the root page's slug can be whatever it likes... return {} # Save the current active language current_language = get_language() siblings = page.get_siblings(inclusive=False).specific() errors = {} for language in mt_settings.AVAILABLE_LANGUAGES: # Temporarily activate every language because even though there might # be no repeated value for slug_pt the fallback of an empty slug could # already be in use trans_real.activate(language) siblings_slugs = [sibling.slug for sibling in siblings] if page.specific.slug in siblings_slugs: errors[build_localized_fieldname('slug', language)] = _("This slug is already in use") # Re-enable the original language trans_real.activate(current_language) return errors def _patch_clean(model): old_clean = model.clean def clean(self): errors = _validate_slugs(self) if errors: raise ValidationError(errors) # Call the original clean method to avoid losing validations old_clean(self) model.clean = clean def _localized_update_descendant_url_paths(self, old_url_path, new_url_path, language): localized_url_path = build_localized_fieldname('url_path', language) cursor = connection.cursor() if connection.vendor == 'sqlite': update_statement = """ UPDATE wagtailcore_page SET {localized_url_path} = %s || substr({localized_url_path}, %s) WHERE path LIKE %s AND id <> %s """.format(localized_url_path=localized_url_path) elif connection.vendor == 'mysql': update_statement = """ UPDATE wagtailcore_page SET {localized_url_path}= CONCAT(%s, substring({localized_url_path}, %s)) WHERE path LIKE %s AND id <> %s """.format(localized_url_path=localized_url_path) elif connection.vendor in ('mssql', 'microsoft'): update_statement = """ UPDATE wagtailcore_page SET {localized_url_path}= CONCAT(%s, (SUBSTRING({localized_url_path}, 0, %s))) WHERE path LIKE %s AND id <> %s """.format(localized_url_path=localized_url_path) else: update_statement = """ UPDATE wagtailcore_page SET {localized_url_path} = %s || substring({localized_url_path} from %s) WHERE path LIKE %s AND id <> %s """.format(localized_url_path=localized_url_path) cursor.execute(update_statement, [new_url_path, len(old_url_path) + 1, self.path + '%', self.id]) class LocalizedSaveDescriptor(object): def __init__(self, f): self.func = f self.__name__ = 'localized_{}'.format(f.__name__) def __call__(self, *args, **kwargs): # when updating, save doesn't check if slug_xx has changed so it can only detect changes in slug # from current language. We need to ensure that if a given localized slug changes we call set_url_path instance = args[0] if not instance.id: # creating a record, wagtail will call set_url_path, nothing to do. return self.func(*args, **kwargs) old_record = None changed_localized_slugs = [] for language in mt_settings.AVAILABLE_LANGUAGES: localized_slug = build_localized_fieldname('slug', language) # similar logic used in save if not ('update_fields' in kwargs and localized_slug not in kwargs['update_fields']): old_record = old_record or instance.__class__.objects.get(id=instance.id) if getattr(old_record, localized_slug) != getattr(instance, localized_slug): changed_localized_slugs.append(language) # if any language other than current language had it slug changed # we'll execute set_url_path if len(changed_localized_slugs) > 1 or \ (len(changed_localized_slugs) == 1 and changed_localized_slugs[0] != get_language()): instance.set_url_path(instance.get_parent()) result = self.func(*args, **kwargs) # update children paths for language in changed_localized_slugs: localized_url_path = build_localized_fieldname('url_path', language) old_url_path = getattr(old_record, localized_url_path) new_url_path = getattr(instance, localized_url_path) _localized_update_descendant_url_paths(instance, old_url_path, new_url_path, language) return result def __get__(self, instance, owner=None): return types.MethodType(self, instance) if instance else self def _patch_stream_field_meaningful_value(field): old_meaningful_value = field.meaningful_value def meaningful_value(self, val, undefined): """ Check if val is considered non-empty. """ if isinstance(val, StreamValue): return len(val.stream_data) != 0 return old_meaningful_value(self, val, undefined) field.meaningful_value = meaningful_value.__get__(field) def _patch_pre_save(field): if not ORIGINAL_SLUG_LANGUAGE: return if ORIGINAL_SLUG_LANGUAGE == 'default': reference_slug_language = mt_settings.DEFAULT_LANGUAGE else: reference_slug_language = ORIGINAL_SLUG_LANGUAGE def pre_save(self, model_instance, add): """ Returns slug field's value using the language set by `WAGTAILMODELTRANSLATION_ORIGINAL_SLUG_LANGUAGE` just before saving. """ current_language = get_language() # using ORIGINAL_SLUG_LANGUAGE makes Page's slug value consistent trans_real.activate(reference_slug_language) value = getattr(model_instance, self.attname) trans_real.activate(current_language) return value field.pre_save = pre_save.__get__(field) def patch_wagtail_models(): # After all models being registered the Page or BaseSetting subclasses and snippets are patched registered_models = translator.get_registered_models() # We need to sort the models to ensure that subclasses of a model are registered first, # or else if the panels are inherited all the changes on the subclass would be # reflected in the superclass registered_models.sort(key=compare_class_tree_depth) for model_class in registered_models: if issubclass(model_class, Page) or model_class in get_snippet_models() or issubclass(model_class, BaseSetting): WagtailTranslator(model_class)