diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 01482121ad..e4759a7690 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -38,6 +38,7 @@ Changelog
* Remove markup around rich text rendering by default, provide a way to use old behaviour via `wagtail.contrib.legacy.richtext` (Coen van der Kamp, Dan Braghis)
* Apply title length normalisation to improve ranking on PostgreSQL search (Karl Hobley)
* Add `WAGTAIL_TIME_FORMAT` setting (Jacob Topp-Mugglestone)
+ * Add ability to import redirects from an uploaded file (CSV, TSV, XLX, and XLXS) (Martin Sandström)
* Fix: Support IPv6 domain (Alex Gleason, Coen van der Kamp)
* Fix: Ensure link to add a new user works when no users are visible in the users list (LB (Ben Johnston))
* Fix: `AbstractEmailForm` saved submission fields are now aligned with the email content fields, `form.cleaned_data` will be used instead of `form.fields` (Haydn Greatnews)
diff --git a/docs/reference/contrib/redirects.rst b/docs/reference/contrib/redirects.rst
index e8e3e52af5..5f9c2ff712 100644
--- a/docs/reference/contrib/redirects.rst
+++ b/docs/reference/contrib/redirects.rst
@@ -21,10 +21,10 @@ The ``redirects`` module is not enabled by default. To install it, add ``wagtail
'wagtail.contrib.redirects',
]
-
+
MIDDLEWARE = [
# ...
- # all other django middlware first
+ # all other django middlware first
'wagtail.contrib.redirects.middleware.RedirectMiddleware',
]
@@ -40,6 +40,44 @@ Page model recipe of to have redirects created automatically when changing a pag
For an editor's guide to the interface, see :ref:`managing_redirects`.
+
+Management commands
+===================
+
+import_redirects
+----------------
+
+.. code-block:: console
+
+ $ ./manage.py import_redirects
+
+This command imports and creates redirects from a file supplied by the user.
+
+Options:
+
+ - **src**
+ This is the path to the file you wish to import redirects from.
+
+ - **site**
+ This is the **site** for the site you wish to save redirects to.
+
+ - **permanent**
+ If the redirects imported should be **permanent** (True) or not (False). It's True by default.
+
+ - **from**
+ The column index you want to use as redirect from value.
+
+ - **to**
+ The column index you want to use as redirect to value.
+
+ - **dry_run**
+ Lets you run a import without doing any changes.
+
+ - **ask**
+ Lets you inspect and approve each redirect before it is created.
+
+
+
The ``Redirect`` class
======================
diff --git a/docs/releases/2.10.rst b/docs/releases/2.10.rst
index 454a5787dc..47bdd6d663 100644
--- a/docs/releases/2.10.rst
+++ b/docs/releases/2.10.rst
@@ -51,6 +51,7 @@ Other features
* Remove markup around rich text rendering by default, provide a way to use old behaviour via ``wagtail.contrib.legacy.richtext``. See :ref:`legacy_richtext`. (Coen van der Kamp, Dan Braghis)
* Add ``WAGTAIL_TIME_FORMAT`` setting (Jacob Topp-Mugglestone)
* Apply title length normalisation to improve ranking on PostgreSQL search (Karl Hobley)
+ * Add ability to import redirects from an uploaded file (CSV, TSV, XLX, and XLXS) (Martin Sandström)
Bug fixes
diff --git a/setup.py b/setup.py
index e44a9f467f..5e40c6bdcd 100755
--- a/setup.py
+++ b/setup.py
@@ -36,6 +36,7 @@ install_requires = [
"requests>=2.11.1,<3.0",
"l18n>=2018.5",
"xlsxwriter>=1.2.8,<2.0",
+ "tablib[xls,xlsx]>=0.14.0",
]
# Testing dependencies
diff --git a/wagtail/contrib/redirects/base_formats.py b/wagtail/contrib/redirects/base_formats.py
new file mode 100644
index 0000000000..a56f1a8286
--- /dev/null
+++ b/wagtail/contrib/redirects/base_formats.py
@@ -0,0 +1,228 @@
+"""
+Copyright (c) Bojan Mihelac and individual contributors.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+# https://raw.githubusercontent.com/django-import-export/django-import-export/master/import_export/formats/base_formats.py
+from importlib import import_module
+import tablib
+
+
+class Format:
+ def get_title(self):
+ return type(self)
+
+ def create_dataset(self, in_stream):
+ """
+ Create dataset from given string.
+ """
+ raise NotImplementedError()
+
+ def export_data(self, dataset, **kwargs):
+ """
+ Returns format representation for given dataset.
+ """
+ raise NotImplementedError()
+
+ def is_binary(self):
+ """
+ Returns if this format is binary.
+ """
+ return True
+
+ def get_read_mode(self):
+ """
+ Returns mode for opening files.
+ """
+ return 'rb'
+
+ def get_extension(self):
+ """
+ Returns extension for this format files.
+ """
+ return ""
+
+ def get_content_type(self):
+ # For content types see
+ # https://www.iana.org/assignments/media-types/media-types.xhtml
+ return 'application/octet-stream'
+
+ @classmethod
+ def is_available(cls):
+ return True
+
+ def can_import(self):
+ return False
+
+ def can_export(self):
+ return False
+
+
+class TablibFormat(Format):
+ TABLIB_MODULE = None
+ CONTENT_TYPE = 'application/octet-stream'
+
+ def get_format(self):
+ """
+ Import and returns tablib module.
+ """
+ try:
+ # Available since tablib 1.0
+ from tablib.formats import registry
+ except ImportError:
+ return import_module(self.TABLIB_MODULE)
+ else:
+ key = self.TABLIB_MODULE.split('.')[-1].replace('_', '')
+ return registry.get_format(key)
+
+ @classmethod
+ def is_available(cls):
+ try:
+ cls().get_format()
+ except (tablib.core.UnsupportedFormat, ImportError):
+ return False
+ return True
+
+ def get_title(self):
+ return self.get_format().title
+
+ def create_dataset(self, in_stream, **kwargs):
+ return tablib.import_set(in_stream, format=self.get_title())
+
+ def export_data(self, dataset, **kwargs):
+ return dataset.export(self.get_title(), **kwargs)
+
+ def get_extension(self):
+ return self.get_format().extensions[0]
+
+ def get_content_type(self):
+ return self.CONTENT_TYPE
+
+ def can_import(self):
+ return hasattr(self.get_format(), 'import_set')
+
+ def can_export(self):
+ return hasattr(self.get_format(), 'export_set')
+
+
+class TextFormat(TablibFormat):
+ def get_read_mode(self):
+ return 'r'
+
+ def is_binary(self):
+ return False
+
+
+class CSV(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._csv'
+ CONTENT_TYPE = 'text/csv'
+
+ def create_dataset(self, in_stream, **kwargs):
+ return super().create_dataset(in_stream, **kwargs)
+
+
+class JSON(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._json'
+ CONTENT_TYPE = 'application/json'
+
+
+class YAML(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._yaml'
+ # See https://stackoverflow.com/questions/332129/yaml-mime-type
+ CONTENT_TYPE = 'text/yaml'
+
+
+class TSV(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._tsv'
+ CONTENT_TYPE = 'text/tab-separated-values'
+
+ def create_dataset(self, in_stream, **kwargs):
+ return super().create_dataset(in_stream, **kwargs)
+
+
+class ODS(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._ods'
+ CONTENT_TYPE = 'application/vnd.oasis.opendocument.spreadsheet'
+
+
+class HTML(TextFormat):
+ TABLIB_MODULE = 'tablib.formats._html'
+ CONTENT_TYPE = 'text/html'
+
+
+class XLS(TablibFormat):
+ TABLIB_MODULE = 'tablib.formats._xls'
+ CONTENT_TYPE = 'application/vnd.ms-excel'
+
+ def create_dataset(self, in_stream):
+ """
+ Create dataset from first sheet.
+ """
+ import xlrd
+ xls_book = xlrd.open_workbook(file_contents=in_stream)
+ dataset = tablib.Dataset()
+ sheet = xls_book.sheets()[0]
+
+ dataset.headers = sheet.row_values(0)
+ for i in range(1, sheet.nrows):
+ dataset.append(sheet.row_values(i))
+ return dataset
+
+
+class XLSX(TablibFormat):
+ TABLIB_MODULE = 'tablib.formats._xlsx'
+ CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+
+ def create_dataset(self, in_stream):
+ """
+ Create dataset from first sheet.
+ """
+ from io import BytesIO
+ import openpyxl
+ xlsx_book = openpyxl.load_workbook(BytesIO(in_stream), read_only=True)
+
+ dataset = tablib.Dataset()
+ sheet = xlsx_book.active
+
+ # obtain generator
+ rows = sheet.rows
+ dataset.headers = [cell.value for cell in next(rows)]
+
+ for row in rows:
+ row_values = [cell.value for cell in row]
+ dataset.append(row_values)
+ return dataset
+
+
+#: These are the default formats for import and export. Whether they can be
+#: used or not is depending on their implementation in the tablib library.
+DEFAULT_FORMATS = [fmt for fmt in (
+ CSV,
+ XLS,
+ XLSX,
+ TSV,
+ ODS,
+ JSON,
+ YAML,
+ HTML,
+) if fmt.is_available()]
diff --git a/wagtail/contrib/redirects/forms.py b/wagtail/contrib/redirects/forms.py
index 4af2061d1c..9437d84ca9 100644
--- a/wagtail/contrib/redirects/forms.py
+++ b/wagtail/contrib/redirects/forms.py
@@ -1,3 +1,5 @@
+import os
+
from django import forms
from django.utils.translation import gettext_lazy as _
@@ -42,3 +44,60 @@ class RedirectForm(forms.ModelForm):
class Meta:
model = Redirect
fields = ('old_path', 'site', 'is_permanent', 'redirect_page', 'redirect_link')
+
+
+class ImportForm(forms.Form):
+ import_file = forms.FileField(
+ label=_("File to import"),
+ )
+
+ def __init__(self, allowed_extensions, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ accept = ",".join(
+ [".{}".format(x) for x in allowed_extensions]
+ )
+ self.fields["import_file"].widget = forms.FileInput(
+ attrs={"accept": accept}
+ )
+
+ uppercased_extensions = [x.upper() for x in allowed_extensions]
+ allowed_extensions_text = ", ".join(uppercased_extensions)
+ help_text = _(
+ "Supported formats: %(supported_formats)s."
+ ) % {
+ 'supported_formats': allowed_extensions_text,
+ }
+ self.fields["import_file"].help_text = help_text
+
+
+class ConfirmImportForm(forms.Form):
+ from_index = forms.ChoiceField(label=_("From field"), choices=(),)
+ to_index = forms.ChoiceField(label=_("To field"), choices=(),)
+ site = forms.ModelChoiceField(
+ label=_("From site"),
+ queryset=Site.objects.all(),
+ required=False,
+ empty_label=_("All sites"),
+ )
+ permanent = forms.BooleanField(initial=True, required=False)
+ import_file_name = forms.CharField(widget=forms.HiddenInput())
+ original_file_name = forms.CharField(widget=forms.HiddenInput())
+ input_format = forms.CharField(widget=forms.HiddenInput())
+
+ def __init__(self, headers, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ choices = []
+ for i, f in enumerate(headers):
+ choices.append([str(i), f])
+ if len(headers) > 1:
+ choices.insert(0, ("", "---"))
+
+ self.fields["from_index"].choices = choices
+ self.fields["to_index"].choices = choices
+
+ def clean_import_file_name(self):
+ data = self.cleaned_data["import_file_name"]
+ data = os.path.basename(data)
+ return data
diff --git a/wagtail/contrib/redirects/management/__init__.py b/wagtail/contrib/redirects/management/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/wagtail/contrib/redirects/management/commands/__init__.py b/wagtail/contrib/redirects/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/wagtail/contrib/redirects/management/commands/import_redirects.py b/wagtail/contrib/redirects/management/commands/import_redirects.py
new file mode 100644
index 0000000000..51d8f4b775
--- /dev/null
+++ b/wagtail/contrib/redirects/management/commands/import_redirects.py
@@ -0,0 +1,186 @@
+import os
+
+import tablib
+from django.core.management.base import BaseCommand
+
+from wagtail.contrib.redirects.forms import RedirectForm
+from wagtail.contrib.redirects.utils import get_format_cls_by_extension, get_supported_extensions
+from wagtail.core.models import Site
+
+
+class Command(BaseCommand):
+ help = "Imports redirects from .csv, .xls, .xlsx"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--src", help="Path to file", type=str, required=True,
+ )
+ parser.add_argument(
+ "--site", help="The site where redirects will be associated", type=int,
+ )
+ parser.add_argument(
+ "--permanent",
+ help="Save redirects as permanent redirects",
+ type=bool,
+ default=True,
+ )
+ parser.add_argument(
+ "--from",
+ help="The column where to read from link",
+ default=0,
+ type=int,
+ )
+ parser.add_argument(
+ "--to", help="The column where to read to link", default=1, type=int,
+ )
+ parser.add_argument(
+ "--dry_run",
+ action="store_true",
+ help="Run only in test mode, will not create redirects",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Run only in test mode, will not create redirects",
+ )
+ parser.add_argument(
+ "--ask",
+ help="Ask before creating",
+ action="store_true",
+ )
+ parser.add_argument(
+ "--format",
+ help="Source file format (example: .csv, .xls etc)",
+ choices=get_supported_extensions(),
+ type=str,
+ )
+ parser.add_argument(
+ "--offset", help="Import starting with index", type=int, default=None
+ )
+ parser.add_argument(
+ "--limit", help="Limit import to num items", type=int, default=None
+ )
+
+ def handle(self, *args, **options):
+ src = options["src"]
+ from_index = options.pop("from")
+ to_index = options.pop("to")
+ site_id = options.pop("site", None)
+ permament = options.pop("permanent")
+
+ dry_run = options.pop("dry_run", False) or options.pop("dry-run", False)
+ format_ = options.pop("format", None)
+ ask = options.pop("ask")
+ offset = options.pop("offset")
+ limit = options.pop("limit")
+
+ errors = []
+ successes = 0
+ skipped = 0
+ total = 0
+ site = None
+
+ if site_id:
+ site = Site.objects.get(id=site_id)
+
+ if not os.path.exists(src):
+ raise Exception("Missing file '{0}'".format(src))
+
+ if not os.path.getsize(src) > 0:
+ raise Exception("File '{0}' is empty".format(src))
+
+ _, extension = os.path.splitext(src)
+ extension = extension.lstrip(".")
+
+ if not format_:
+ format_ = extension
+
+ if not get_format_cls_by_extension(format_):
+ raise Exception("Invalid format '{0}'".format(extension))
+
+ if extension in ["xls", "xlsx"]:
+ mode = "rb"
+ else:
+ mode = "r"
+
+ with open(src, mode) as fh:
+ imported_data = tablib.Dataset().load(fh.read(), format=format_)
+
+ sample_data = tablib.Dataset(
+ *imported_data[: min(len(imported_data), 4)],
+ headers=imported_data.headers
+ )
+
+ try:
+ self.stdout.write("Sample data:")
+ self.stdout.write(str(sample_data))
+ except Exception:
+ self.stdout.write("Warning: Cannot display sample data")
+
+ self.stdout.write("--------------")
+
+ if site:
+ self.stdout.write("Using site: {0}".format(site.hostname))
+
+ self.stdout.write("Importing redirects:")
+
+ if offset:
+ imported_data = imported_data[offset:]
+ if limit:
+ imported_data = imported_data[:limit]
+
+ for row in imported_data:
+ total += 1
+
+ from_link = row[from_index]
+ to_link = row[to_index]
+
+ data = {
+ "old_path": from_link,
+ "redirect_link": to_link,
+ "is_permanent": permament,
+ }
+
+ if site:
+ data["site"] = site.pk
+
+ form = RedirectForm(data)
+ if not form.is_valid():
+ error = form.errors.as_text().replace("\n", "")
+ self.stdout.write(
+ "{}. Error: {} -> {} (Reason: {})".format(
+ total, from_link, to_link, error,
+ )
+ )
+ errors.append(error)
+ continue
+
+ if ask:
+ answer = get_input(
+ "{}. Found {} -> {} Create? Y/n: ".format(
+ total, from_link, to_link,
+ )
+ )
+
+ if answer != "Y":
+ skipped += 1
+ continue
+ else:
+ self.stdout.write("{}. {} -> {}".format(total, from_link, to_link,))
+
+ if dry_run:
+ successes += 1
+ continue
+
+ form.save()
+ successes += 1
+
+ self.stdout.write("\n")
+ self.stdout.write("Found: {}".format(total))
+ self.stdout.write("Created: {}".format(successes))
+ self.stdout.write("Skipped : {}".format(skipped))
+ self.stdout.write("Errors: {}".format(len(errors)))
+
+
+def get_input(msg): # pragma: no cover
+ return input(msg)
diff --git a/wagtail/contrib/redirects/static/wagtailredirects/css/index.css b/wagtail/contrib/redirects/static/wagtailredirects/css/index.css
new file mode 100644
index 0000000000..e56ca4fdf7
--- /dev/null
+++ b/wagtail/contrib/redirects/static/wagtailredirects/css/index.css
@@ -0,0 +1,17 @@
+.listing-with-x-scroll {
+ display: block;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+
+header .has-multiple-actions {
+ display: -webkit-box;
+ display: -moz-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+}
+
+header .has-multiple-actions .actionbutton {
+ margin-left: 10px;
+}
diff --git a/wagtail/contrib/redirects/templates/wagtailredirects/choose_import_file.html b/wagtail/contrib/redirects/templates/wagtailredirects/choose_import_file.html
new file mode 100644
index 0000000000..8ee4898914
--- /dev/null
+++ b/wagtail/contrib/redirects/templates/wagtailredirects/choose_import_file.html
@@ -0,0 +1,52 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% trans "Redirects" %}{% endblock %}
+
+{% block extra_js %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block content %}
+ {% trans "Import redirects" as header_title %}
+ {% include "wagtailadmin/shared/header.html" with title=header_title icon="redirect" %}
+
+
+
+ {% blocktrans %}
+
Select a file where redirects are separated into rows and contains the columns representing from and to (they can be named anything).
+
After submitting you will be taken to a confirmation view where you can customize your redirects before import.
+ {% endblocktrans %}
+
+
+
+ {% if form.non_field_errors %}
+
+
+ {% for error in form.non_field_errors %}
+
{{ error }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/wagtail/contrib/redirects/templates/wagtailredirects/confirm_import.html b/wagtail/contrib/redirects/templates/wagtailredirects/confirm_import.html
new file mode 100644
index 0000000000..3b53330362
--- /dev/null
+++ b/wagtail/contrib/redirects/templates/wagtailredirects/confirm_import.html
@@ -0,0 +1,58 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+
+{% block titletag %}{% trans "Confirm import" %}{% endblock %}
+{% block content %}
+ {% trans "Import redirects" as header_title %}
+ {% trans "Confirm import" as header_subtitle %}
+ {% include "wagtailadmin/shared/header.html" with title=header_title subtitle=header_subtitle icon="redirect" %}
+
+ {% if form.non_field_errors %}
+
+
+ {% for error in form.non_field_errors %}
+
{{ error }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/wagtail/contrib/redirects/templates/wagtailredirects/import_summary.html b/wagtail/contrib/redirects/templates/wagtailredirects/import_summary.html
new file mode 100644
index 0000000000..b958e05b07
--- /dev/null
+++ b/wagtail/contrib/redirects/templates/wagtailredirects/import_summary.html
@@ -0,0 +1,34 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% trans "Summary" %}{% endblock %}
+{% block content %}
+ {% trans "Import redirects" as header_title %}
+ {% trans "Summary" as header_subtitle %}
+ {% include "wagtailadmin/shared/header.html" with title=header_title subtitle=header_subtitle icon="redirect" %}
+
+
+ {% blocktrans with total=import_summary.total successes=import_summary.successes errors=import_summary.errors_count %}Found {{ total }} redirects, created {{ successes }} and found {{ errors }} errors.{% endblocktrans %}
+
+
+
+
+
+
{% trans "From" %}
+
{% trans "To" %}
+
{% trans "Error" %}
+
+
+
+ {% for error in import_summary.errors %}
+
+ {% for value in error %}
+
{{ value }}
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ {% trans "Continue" %}
+
+{% endblock %}
diff --git a/wagtail/contrib/redirects/templates/wagtailredirects/index.html b/wagtail/contrib/redirects/templates/wagtailredirects/index.html
index 02622597f6..73b7591059 100644
--- a/wagtail/contrib/redirects/templates/wagtailredirects/index.html
+++ b/wagtail/contrib/redirects/templates/wagtailredirects/index.html
@@ -1,5 +1,7 @@
{% extends "wagtailadmin/base.html" %}
{% load i18n %}
+{% load static %}
+
{% block titletag %}{% trans "Redirects" %}{% endblock %}
{% block extra_js %}
@@ -14,11 +16,41 @@
{% endblock %}
{% block content %}
+
+
{% trans "Redirects" as redirects_str %}
- {% trans "Add redirect" as add_str %}
{% if user_can_add %}
{% url "wagtailredirects:add" as add_link %}
- {% include "wagtailadmin/shared/header.html" with title=redirects_str icon="redirect" action_url=add_link action_text=add_str search_url="wagtailredirects:index" %}
+ {% trans "Add redirect" as add_str %}
+ {% url "wagtailredirects:start_import" as import_link %}
+ {% trans "Import redirects" as import_str %}
+
+
+ {% block breadcrumb %}{% endblock %}
+