From 301d1bc7f52fffc18b8cb34fbde01633bc1599ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Sandstr=C3=B6m?= <martin@marteinn.se> Date: Mon, 11 May 2020 19:19:20 +0200 Subject: [PATCH] Add ability to import redirects from a file wagtail.contrib.redirects * Add support for importing redirects via tsv, csv, xls and xlsx files * Add import_redirects management command to redirects documentation --- CHANGELOG.txt | 1 + docs/reference/contrib/redirects.rst | 42 +- docs/releases/2.10.rst | 1 + setup.py | 1 + wagtail/contrib/redirects/base_formats.py | 228 ++++++++++ wagtail/contrib/redirects/forms.py | 59 +++ .../contrib/redirects/management/__init__.py | 0 .../redirects/management/commands/__init__.py | 0 .../management/commands/import_redirects.py | 186 ++++++++ .../static/wagtailredirects/css/index.css | 17 + .../wagtailredirects/choose_import_file.html | 52 +++ .../wagtailredirects/confirm_import.html | 58 +++ .../wagtailredirects/import_summary.html | 34 ++ .../templates/wagtailredirects/index.html | 38 +- .../contrib/redirects/tests/files/example.csv | 4 + .../redirects/tests/files/example.json | 14 + .../redirects/tests/files/example.numbers | Bin 0 -> 88518 bytes .../contrib/redirects/tests/files/example.tsv | 4 + .../contrib/redirects/tests/files/example.xls | Bin 0 -> 8704 bytes .../redirects/tests/files/example.xlsx | Bin 0 -> 5949 bytes .../redirects/tests/files/example.yaml | 5 + .../redirects/tests/files/example_faulty.csv | Bin 0 -> 8704 bytes .../tests/test_import_admin_views.py | 415 ++++++++++++++++++ .../redirects/tests/test_import_command.py | 335 ++++++++++++++ .../redirects/tests/test_import_forms.py | 23 + .../redirects/tests/test_import_utils.py | 59 +++ .../{tests.py => tests/test_redirects.py} | 0 wagtail/contrib/redirects/tmp_storages.py | 117 +++++ wagtail/contrib/redirects/urls.py | 2 + wagtail/contrib/redirects/utils.py | 54 +++ wagtail/contrib/redirects/views.py | 225 +++++++++- 31 files changed, 1967 insertions(+), 7 deletions(-) create mode 100644 wagtail/contrib/redirects/base_formats.py create mode 100644 wagtail/contrib/redirects/management/__init__.py create mode 100644 wagtail/contrib/redirects/management/commands/__init__.py create mode 100644 wagtail/contrib/redirects/management/commands/import_redirects.py create mode 100644 wagtail/contrib/redirects/static/wagtailredirects/css/index.css create mode 100644 wagtail/contrib/redirects/templates/wagtailredirects/choose_import_file.html create mode 100644 wagtail/contrib/redirects/templates/wagtailredirects/confirm_import.html create mode 100644 wagtail/contrib/redirects/templates/wagtailredirects/import_summary.html create mode 100644 wagtail/contrib/redirects/tests/files/example.csv create mode 100644 wagtail/contrib/redirects/tests/files/example.json create mode 100755 wagtail/contrib/redirects/tests/files/example.numbers create mode 100644 wagtail/contrib/redirects/tests/files/example.tsv create mode 100644 wagtail/contrib/redirects/tests/files/example.xls create mode 100644 wagtail/contrib/redirects/tests/files/example.xlsx create mode 100644 wagtail/contrib/redirects/tests/files/example.yaml create mode 100644 wagtail/contrib/redirects/tests/files/example_faulty.csv create mode 100644 wagtail/contrib/redirects/tests/test_import_admin_views.py create mode 100644 wagtail/contrib/redirects/tests/test_import_command.py create mode 100644 wagtail/contrib/redirects/tests/test_import_forms.py create mode 100644 wagtail/contrib/redirects/tests/test_import_utils.py rename wagtail/contrib/redirects/{tests.py => tests/test_redirects.py} (100%) create mode 100644 wagtail/contrib/redirects/tmp_storages.py create mode 100644 wagtail/contrib/redirects/utils.py 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 }} + <script> + window.headerSearch = { + url: "{% url 'wagtailredirects:index' %}", + termInput: "#id_q", + targetOutput: "#redirects-results" + } + </script> +{% endblock %} + +{% block content %} + {% trans "Import redirects" as header_title %} + {% include "wagtailadmin/shared/header.html" with title=header_title icon="redirect" %} + + <div class="nice-padding"> + <div class="help-block help-info"> + {% blocktrans %} + <p>Select a file where redirects are separated into rows and contains the columns representing <code>from</code> and <code>to</code> (they can be named anything).</p> + <p>After submitting you will be taken to a confirmation view where you can customize your redirects before import.</p> + {% endblocktrans %} + </div> + </div> + + {% if form.non_field_errors %} + <div class="messages"> + <ul> + {% for error in form.non_field_errors %} + <li class="error">{{ error }}</li> + {% endfor %} + </ul> + </div> + {% endif %} + + <form action="" method="POST" class="nice-padding" novalidate enctype="multipart/form-data"> + {% csrf_token %} + + <ul class="fields"> + {% for field in form.visible_fields %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endfor %} + + <li> + <input type="submit" value="{% trans 'Import' %}" class="button" /> + </li> + </ul> + </form> +{% 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 %} + <div class="messages"> + <ul> + {% for error in form.non_field_errors %} + <li class="error">{{ error }}</li> + {% endfor %} + </ul> + </div> + {% endif %} + + <form action="{% url 'wagtailredirects:process_import' %}" method="POST" class="nice-padding" novalidate enctype="multipart/form-data"> + {% csrf_token %} + + {% for field in form.hidden_fields %}{{ field }}{% endfor %} + + <ul class="fields"> + {% for field in form.visible_fields %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endfor %} + + <li> + <input type="submit" value="{% trans 'Confirm' %}" class="button" /> + <a href="{% url 'wagtailredirects:index' %}" class="button button-secondary"> + {% trans 'Cancel' %} + </a> + </li> + </ul> + + <h2>{% trans "Preview" %}</h2> + <table class="listing listing-with-x-scroll"> + <thead> + <tr> + {% for column in dataset.headers %} + <td>{{ column }}</td> + {% endfor %} + </tr> + </thead> + <tbody> + {% for row in dataset %} + <tr> + {% for column in row %} + <td>{{ column }}</td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + </form> +{% 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" %} + <section id="summary" class="nice-padding"> + <p class="help-block help-warning"> + {% 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 %} + </p> + + <table class="listing"> + <thead> + <tr> + <th>{% trans "From" %}</th> + <th>{% trans "To" %}</th> + <th>{% trans "Error" %}</th> + </tr> + </thead> + <tbody> + {% for error in import_summary.errors %} + <tr> + {% for value in error %} + <td>{{ value }}</td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + + <a href="{% url 'wagtailredirects:index' %}" class="button">{% trans "Continue" %}</a> + </section> +{% 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 %} + <link rel="stylesheet" type="text/css" href="{% static 'wagtailredirects/css/index.css' %}"> + {% 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 %} + + <header class="hasform"> + {% block breadcrumb %}{% endblock %} + <div class="row nice-padding"> + <div class="left"> + <div class="col header-title"> + <h1 class="icon icon-redirect">{{ redirects_str }}</h1> + </div> + <form class="col search-form" action="{% url "wagtailredirects:index" %}{% if query_parameters %}?{{ query_parameters }}{% endif %}" method="get" novalidate role="search"> + <ul class="fields"> + {% for field in search_form %} + {% include "wagtailadmin/shared/field_as_li.html" with field=field field_classes="field-small iconfield" input_classes="icon-search" %} + {% endfor %} + <li class="submit visuallyhidden"><input type="submit" value="Search" class="button" /></li> + </ul> + </form> + </div> + <div class="right has-multiple-actions"> + <div class="actionbutton"> + <a href="{{ add_link }}" class="button bicolor icon icon-plus">{{ add_str }}</a> + </div> + <div class="actionbutton"> + <a href="{{ import_link }}" class="button bicolor icon icon-doc-full-inverse">{{ import_str }}</a> + </div> + </div> + </div> + </header> {% else %} {% include "wagtailadmin/shared/header.html" with title=redirects_str icon="redirect" search_url="wagtailredirects:index" %} {% endif %} @@ -28,5 +60,5 @@ {% include "wagtailredirects/results.html" %} </div> </div> - + {% endblock %} diff --git a/wagtail/contrib/redirects/tests/files/example.csv b/wagtail/contrib/redirects/tests/files/example.csv new file mode 100644 index 0000000000..e3bfb72f28 --- /dev/null +++ b/wagtail/contrib/redirects/tests/files/example.csv @@ -0,0 +1,4 @@ +from,to +/hello,http://hello.com/random/ +/goodbye,http://hello.com/goodbye/ +/goodbye,/cake/ diff --git a/wagtail/contrib/redirects/tests/files/example.json b/wagtail/contrib/redirects/tests/files/example.json new file mode 100644 index 0000000000..f47092e3f7 --- /dev/null +++ b/wagtail/contrib/redirects/tests/files/example.json @@ -0,0 +1,14 @@ +[ + { + "from": "/hello-from-json", + "to": "http://hello.com/random/" + }, + { + "from": "/goodbye-from-json", + "to": "http://hello.com/goodbye/" + }, + { + "from": "/goodbye-from-json", + "to": "/goodbye-not-working/" + } +] diff --git a/wagtail/contrib/redirects/tests/files/example.numbers b/wagtail/contrib/redirects/tests/files/example.numbers new file mode 100755 index 0000000000000000000000000000000000000000..00c8588c41b5442a4733ecf0484fca367713ca18 GIT binary patch literal 88518 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 literal 0 HcmV?d00001 diff --git a/wagtail/contrib/redirects/tests/files/example.tsv b/wagtail/contrib/redirects/tests/files/example.tsv new file mode 100644 index 0000000000..79260eee9b --- /dev/null +++ b/wagtail/contrib/redirects/tests/files/example.tsv @@ -0,0 +1,4 @@ +from to +/hello http://hello.com/random/ +/goodbye http://hello.com/goodbye/ +/goodbye /cake/ diff --git a/wagtail/contrib/redirects/tests/files/example.xls b/wagtail/contrib/redirects/tests/files/example.xls new file mode 100644 index 0000000000000000000000000000000000000000..4e587f34695127b1c335f83be8519b1306f07b43 GIT binary patch literal 8704 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 literal 0 HcmV?d00001 diff --git a/wagtail/contrib/redirects/tests/files/example.xlsx b/wagtail/contrib/redirects/tests/files/example.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1dc3a156a03c6c68881b03b54b1c57695fb8987e GIT binary patch literal 5949 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 literal 0 HcmV?d00001 diff --git a/wagtail/contrib/redirects/tests/files/example.yaml b/wagtail/contrib/redirects/tests/files/example.yaml new file mode 100644 index 0000000000..c60134d786 --- /dev/null +++ b/wagtail/contrib/redirects/tests/files/example.yaml @@ -0,0 +1,5 @@ +--- +items: + - one + - two + - three diff --git a/wagtail/contrib/redirects/tests/files/example_faulty.csv b/wagtail/contrib/redirects/tests/files/example_faulty.csv new file mode 100644 index 0000000000000000000000000000000000000000..4e587f34695127b1c335f83be8519b1306f07b43 GIT binary patch literal 8704 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 literal 0 HcmV?d00001 diff --git a/wagtail/contrib/redirects/tests/test_import_admin_views.py b/wagtail/contrib/redirects/tests/test_import_admin_views.py new file mode 100644 index 0000000000..89f8fda9e7 --- /dev/null +++ b/wagtail/contrib/redirects/tests/test_import_admin_views.py @@ -0,0 +1,415 @@ +import os + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse + +from wagtail.contrib.redirects.models import Redirect +from wagtail.core.models import Site +from wagtail.tests.utils import WagtailTestUtils + + +TEST_ROOT = os.path.abspath(os.path.dirname(__file__)) + + +@override_settings( + ALLOWED_HOSTS=["testserver", "localhost", "test.example.com", "other.example.com"] +) +class TestImportAdminViews(TestCase, WagtailTestUtils): + def setUp(self): + self.login() + + def get(self, params={}): + return self.client.get(reverse("wagtailredirects:start_import"), params) + + def post(self, post_data={}, follow=False): + return self.client.post( + reverse("wagtailredirects:start_import"), post_data, follow=follow + ) + + def post_import(self, post_data={}, follow=False): + return self.client.post( + reverse("wagtailredirects:process_import"), post_data, follow=follow + ) + + def test_request_start_with_get_returns_initial_form(self): + response = self.get() + self.assertEqual( + response.templates[0].name, "wagtailredirects/choose_import_file.html", + ) + + def test_empty_import_file_returns_error(self): + response = self.post({ + "import_file": "", + "input_format": "0", + }) + + self.assertTrue("import_file" in response.context["form"].errors) + + def test_non_valid_format_returns_error(self): + f = "{}/files/example.yaml".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + response = self.post( + { + "import_file": upload_file, + }, + follow=True, + ) + + self.assertContains( + response, 'File format of type "yaml" is not supported' + ) + + def test_valid_csv_triggers_confirm_view(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + self.assertEqual(len(response.context["dataset"]), 3) + + def test_import_step(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + } + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/import_summary.html", + ) + + self.assertEqual(Redirect.objects.all().count(), 2) + + def test_permanent_setting(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": False, + } + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/import_summary.html", + ) + + self.assertFalse(Redirect.objects.first().is_permanent) + + def test_site_setting(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + default_site = Site.objects.first() + new_site = Site.objects.create( + hostname="hello.dev", root_page=default_site.root_page, + ) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": False, + "site": new_site.pk, + } + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/import_summary.html", + ) + + self.assertEqual(Redirect.objects.count(), 2) + self.assertEqual(Redirect.objects.first().site, new_site) + + def test_import_xls(self): + f = "{}/files/example.xls".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + }, + follow=True, + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/index.html", + ) + + self.assertEqual(Redirect.objects.all().count(), 3) + + def test_import_xlsx(self): + f = "{}/files/example.xlsx".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + }, + follow=True, + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/index.html", + ) + + self.assertEqual(Redirect.objects.all().count(), 3) + + def test_unicode_error_when_importing(self): + f = "{}/files/example_faulty.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + }, + follow=True, + ) + self.assertTrue( + b"Imported file has a wrong encoding:" in response.content + ) + + def test_not_valid_method_for_import_file(self): + response = self.client.get(reverse("wagtailredirects:process_import")) + self.assertEqual(response.status_code, 405) + + def test_error_in_data_renders_confirm_view_on_import(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + "site": 99, + } + ) + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + def test_import_tsv(self): + f = "{}/files/example.tsv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + } + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/import_summary.html", + ) + + self.assertEqual(Redirect.objects.all().count(), 2) + + @override_settings(WAGTAIL_REDIRECTS_FILE_STORAGE='cache') + def test_import_xlsx_with_cache_store_engine(self): + f = "{}/files/example.xlsx".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "from_index": 0, + "to_index": 1, + "permanent": True, + }, + follow=True, + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/index.html", + ) + + self.assertEqual(Redirect.objects.all().count(), 3) + + @override_settings(WAGTAIL_REDIRECTS_FILE_STORAGE='cache') + def test_process_validation_works_when_using_plaintext_files_and_cache(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + upload_file = SimpleUploadedFile(filename, infile.read()) + + self.assertEqual(Redirect.objects.all().count(), 0) + + response = self.post( + { + "import_file": upload_file, + } + ) + + self.assertEqual( + response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) + + import_response = self.post_import( + { + **response.context["form"].initial, + "permanent": True, + } + ) + + self.assertEqual( + import_response.templates[0].name, + "wagtailredirects/confirm_import.html", + ) diff --git a/wagtail/contrib/redirects/tests/test_import_command.py b/wagtail/contrib/redirects/tests/test_import_command.py new file mode 100644 index 0000000000..91f32ceadf --- /dev/null +++ b/wagtail/contrib/redirects/tests/test_import_command.py @@ -0,0 +1,335 @@ +import os +import tempfile +from io import StringIO +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from wagtail.contrib.redirects.models import Redirect +from wagtail.core.models import Site + + +TEST_ROOT = os.path.abspath(os.path.dirname(__file__)) + + +class TestImportCommand(TestCase): + def test_empty_command_raises_errors(self): + with self.assertRaises(CommandError): + out = StringIO() + call_command("import_redirects", stdout=out) + + def test_missing_file_raises_error(self): + with self.assertRaisesMessage(Exception, "Missing file 'random'"): + out = StringIO() + call_command("import_redirects", src="random", stdout=out) + + def test_invalid_extension_raises_error(self): + f = "{}/files/example.numbers".format(TEST_ROOT) + + with self.assertRaisesMessage(Exception, "Invalid format 'numbers'"): + out = StringIO() + call_command("import_redirects", src=f, stdout=out) + + def test_empty_file_raises_error(self): + empty_file = tempfile.NamedTemporaryFile() + + with self.assertRaisesMessage( + Exception, "File '{}' is empty".format(empty_file.name) + ): + out = StringIO() + call_command("import_redirects", src=empty_file.name, stdout=out) + + def test_header_are_not_imported(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", src=invalid_file.name, stdout=out, format="csv" + ) + + self.assertEqual(Redirect.objects.count(), 0) + + def test_format_gets_picked_up_from_file_extension(self): + f = "{}/files/example.csv".format(TEST_ROOT) + + out = StringIO() + call_command("import_redirects", src=f, stdout=out) + self.assertEqual(Redirect.objects.count(), 2) + + def test_binary_formats_are_supported(self): + f = "{}/files/example.xls".format(TEST_ROOT) + + out = StringIO() + call_command("import_redirects", src=f, stdout=out) + self.assertEqual(Redirect.objects.count(), 3) + + def test_redirect_gets_imported(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", src=invalid_file.name, stdout=out, format="csv" + ) + + self.assertEqual(Redirect.objects.count(), 1) + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + self.assertEqual(redirect.is_permanent, True) + + def test_trailing_slash_gets_stripped(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha/,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", src=invalid_file.name, stdout=out, format="csv" + ) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + + def test_site_id_does_not_exist(self): + with self.assertRaisesMessage(Exception, "Site matching query does not exist"): + out = StringIO() + call_command("import_redirects", src="random", site=5, stdout=out) + + def test_redirect_gets_added_to_site(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha/,http://omega.test/") + invalid_file.seek(0) + + current_site = Site.objects.first() + site = Site.objects.create( + hostname="random.test", root_page=current_site.root_page + ) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + site=site.pk, + stdout=out, + format="csv", + ) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + self.assertEqual(redirect.site, site) + + def test_temporary_redirect(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha/,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + permanent=False, + stdout=out, + format="csv", + ) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + self.assertEqual(redirect.is_permanent, False) + + def test_duplicate_from_links_get_skipped(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha/,http://omega.test/\n") + invalid_file.write("/alpha/,http://omega2.test/\n") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + permanent=False, + format="csv", + stdout=out, + ) + + self.assertEqual(Redirect.objects.count(), 1) + + def test_non_absolute_to_links_get_skipped(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha/,/omega.test/\n") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + permanent=False, + stdout=out, + format="csv", + ) + + self.assertEqual(Redirect.objects.count(), 0) + self.assertIn("Errors: 1", out.getvalue()) + + def test_from_links_are_converted_to_relative(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("http://alpha.test/alpha/,http://omega.test/\n") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", src=invalid_file.name, format="csv", stdout=out + ) + + self.assertEqual(Redirect.objects.count(), 1) + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + + def test_column_index_are_used(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("priority,from,year,to\n") + invalid_file.write("5,/alpha,2020,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + "--src={}".format(invalid_file.name), + "--from=1", + "--to=3", + "--format=csv", + stdout=out, + ) + + self.assertEqual(Redirect.objects.count(), 1) + redirect = Redirect.objects.first() + self.assertEqual(redirect.old_path, "/alpha") + self.assertEqual(redirect.redirect_link, "http://omega.test/") + self.assertEqual(redirect.is_permanent, True) + + def test_nothing_gets_saved_on_dry_run(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + format="csv", + dry_run=True, + stdout=out, + ) + + self.assertEqual(Redirect.objects.count(), 0) + + @patch( + "wagtail.contrib.redirects.management.commands.import_redirects.get_input", + return_value="Y", + ) + def test_successfull_ask_imports_redirect(self, get_input): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + format="csv", + ask=True, + stdout=out, + ) + + self.assertEqual(Redirect.objects.count(), 1) + + @patch( + "wagtail.contrib.redirects.management.commands.import_redirects.get_input", + return_value="N", + ) + def test_native_ask_imports_redirect(self, get_input): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/alpha,http://omega.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + format="csv", + ask=True, + stdout=out, + ) + + self.assertEqual(Redirect.objects.count(), 0) + + def test_offset_parameter(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/one,http://one.test/\n") + invalid_file.write("/two,http://two.test/\n") + invalid_file.write("/three,http://three.test/\n") + invalid_file.write("/four,http://four.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + format="csv", + offset=2, + stdout=out, + ) + + redirects = Redirect.objects.all() + + self.assertEqual(len(redirects), 2) + self.assertEqual(redirects[0].old_path, "/three") + self.assertEqual(redirects[0].redirect_link, "http://three.test/") + self.assertEqual(redirects[0].is_permanent, True) + self.assertEqual(redirects[1].old_path, "/four") + self.assertEqual(redirects[1].redirect_link, "http://four.test/") + + def test_limit_parameter(self): + invalid_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") + invalid_file.write("from,to\n") + invalid_file.write("/one,http://one.test/\n") + invalid_file.write("/two,http://two.test/\n") + invalid_file.write("/three,http://three.test/\n") + invalid_file.write("/four,http://four.test/") + invalid_file.seek(0) + + out = StringIO() + call_command( + "import_redirects", + src=invalid_file.name, + format="csv", + limit=1, + stdout=out, + ) + + redirects = Redirect.objects.all() + + self.assertEqual(len(redirects), 1) + self.assertEqual(redirects[0].old_path, "/one") + self.assertEqual(redirects[0].redirect_link, "http://one.test/") + self.assertEqual(redirects[0].is_permanent, True) diff --git a/wagtail/contrib/redirects/tests/test_import_forms.py b/wagtail/contrib/redirects/tests/test_import_forms.py new file mode 100644 index 0000000000..a8b2ca3031 --- /dev/null +++ b/wagtail/contrib/redirects/tests/test_import_forms.py @@ -0,0 +1,23 @@ +from django.test import TestCase + +from wagtail.contrib.redirects.forms import ConfirmImportForm, ImportForm + + +class TestImportForm(TestCase): + def test_file_input_generates_proper_accept(self): + form = ImportForm(["csv", "tsv"]) + self.assertIn('accept=".csv,.tsv"', form.as_table()) + + +class TestConfirmImportForm(TestCase): + def test_choices_get_appended_with_intro_label_if_multiple(self): + form = ConfirmImportForm(headers=[(0, "From"), (1, "To")]) + first_choice = form.fields["from_index"].choices[0] + self.assertEqual(first_choice[0], "") + self.assertEqual(first_choice[1], "---") + + def test_choices_does_not_get_generated_label_if_single_choice(self): + form = ConfirmImportForm(headers=[(1, "Hi")]) + first_choice = form.fields["from_index"].choices[0] + self.assertNotEqual(first_choice[0], "") + self.assertNotEqual(first_choice[1], "---") diff --git a/wagtail/contrib/redirects/tests/test_import_utils.py b/wagtail/contrib/redirects/tests/test_import_utils.py new file mode 100644 index 0000000000..b33131e0d8 --- /dev/null +++ b/wagtail/contrib/redirects/tests/test_import_utils.py @@ -0,0 +1,59 @@ +import hashlib +import os + +from django.core.files.base import ContentFile +from django.test import TestCase, override_settings + +from wagtail.contrib.redirects.utils import ( + get_file_storage, get_import_formats, write_to_file_storage +) + + +TEST_ROOT = os.path.abspath(os.path.dirname(__file__)) + + +class TestImportUtils(TestCase): + def test_writing_file_with_format(self): + f = "{}/files/example.csv".format(TEST_ROOT) + (_, filename) = os.path.split(f) + + with open(f, "rb") as infile: + content_orig = infile.read() + upload_file = ContentFile(content_orig) + + import_formats = get_import_formats() + import_formats = [ + x for x in import_formats if x.__name__ == "CSV" + ] + input_format = import_formats[0]() + file_storage = write_to_file_storage(upload_file, input_format) + + self.assertEqual(type(file_storage).__name__, "TempFolderStorage") + + file_orig_checksum = hashlib.md5() + file_orig_checksum.update(content_orig) + file_orig_checksum = file_orig_checksum.hexdigest() + + file_new_checksum = hashlib.md5() + with open(file_storage.get_full_path(), "rb") as file_new: + file_new_checksum.update(file_new.read()) + file_new_checksum = file_new_checksum.hexdigest() + + self.assertEqual(file_orig_checksum, file_new_checksum) + + @override_settings(WAGTAIL_REDIRECTS_FILE_STORAGE='cache') + def test_that_cache_storage_are_returned(self): + FileStorage = get_file_storage() + self.assertEqual(FileStorage.__name__, "RedirectsCacheStorage") + + def test_that_temp_folder_storage_are_returned_as_default(self): + FileStorage = get_file_storage() + self.assertEqual(FileStorage.__name__, "TempFolderStorage") + + @override_settings(WAGTAIL_REDIRECTS_FILE_STORAGE='INVALID') + def test_invalid_file_storage_raises_errors(self): + with self.assertRaisesMessage( + Exception, + "Invalid file storage, must be either 'tmp_file' or 'cache'" + ): + get_file_storage() diff --git a/wagtail/contrib/redirects/tests.py b/wagtail/contrib/redirects/tests/test_redirects.py similarity index 100% rename from wagtail/contrib/redirects/tests.py rename to wagtail/contrib/redirects/tests/test_redirects.py diff --git a/wagtail/contrib/redirects/tmp_storages.py b/wagtail/contrib/redirects/tmp_storages.py new file mode 100644 index 0000000000..3741bb9e35 --- /dev/null +++ b/wagtail/contrib/redirects/tmp_storages.py @@ -0,0 +1,117 @@ +""" +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. +""" +# Copied from: https://raw.githubusercontent.com/django-import-export/django-import-export/5795e114210adf250ac6e146db2fa413f38875de/import_export/tmp_storages.py +import os +import tempfile +from uuid import uuid4 + +from django.core.cache import cache +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + + +class BaseStorage: + + def __init__(self, name=None): + self.name = name + + def save(self, data, mode='w'): + raise NotImplementedError + + def read(self, read_mode='r'): + raise NotImplementedError + + def remove(self): + raise NotImplementedError + + +class TempFolderStorage(BaseStorage): + + def open(self, mode='r'): + if self.name: + return open(self.get_full_path(), mode) + else: + tmp_file = tempfile.NamedTemporaryFile(delete=False) + self.name = tmp_file.name + return tmp_file + + def save(self, data, mode='w'): + with self.open(mode=mode) as file: + file.write(data) + + def read(self, mode='r'): + with self.open(mode=mode) as file: + return file.read() + + def remove(self): + os.remove(self.get_full_path()) + + def get_full_path(self): + return os.path.join( + tempfile.gettempdir(), + self.name + ) + + +class CacheStorage(BaseStorage): + """ + By default memcache maximum size per key is 1MB, be careful with large files. + """ + CACHE_LIFETIME = 86400 + CACHE_PREFIX = 'django-import-export-' + + def save(self, data, mode=None): + if not self.name: + self.name = uuid4().hex + cache.set(self.CACHE_PREFIX + self.name, data, self.CACHE_LIFETIME) + + def read(self, read_mode='r'): + return cache.get(self.CACHE_PREFIX + self.name) + + def remove(self): + cache.delete(self.name) + + +class MediaStorage(BaseStorage): + MEDIA_FOLDER = 'django-import-export' + + def save(self, data, mode=None): + if not self.name: + self.name = uuid4().hex + default_storage.save(self.get_full_path(), ContentFile(data)) + + def read(self, read_mode='rb'): + with default_storage.open(self.get_full_path(), mode=read_mode) as f: + return f.read() + + def remove(self): + default_storage.delete(self.get_full_path()) + + def get_full_path(self): + return os.path.join( + self.MEDIA_FOLDER, + self.name + ) diff --git a/wagtail/contrib/redirects/urls.py b/wagtail/contrib/redirects/urls.py index e6a90223e9..0ff65e943d 100644 --- a/wagtail/contrib/redirects/urls.py +++ b/wagtail/contrib/redirects/urls.py @@ -8,4 +8,6 @@ urlpatterns = [ url(r'^add/$', views.add, name='add'), url(r'^(\d+)/$', views.edit, name='edit'), url(r'^(\d+)/delete/$', views.delete, name='delete'), + url(r"^import/$", views.start_import, name="start_import"), + url(r"^import/process/$", views.process_import, name="process_import"), ] diff --git a/wagtail/contrib/redirects/utils.py b/wagtail/contrib/redirects/utils.py new file mode 100644 index 0000000000..d41384bcea --- /dev/null +++ b/wagtail/contrib/redirects/utils.py @@ -0,0 +1,54 @@ +from django.conf import settings + +from wagtail.contrib.redirects.base_formats import DEFAULT_FORMATS +from wagtail.contrib.redirects.tmp_storages import CacheStorage, TempFolderStorage + + +def write_to_file_storage(import_file, input_format): + FileStorage = get_file_storage() + file_storage = FileStorage() + + data = bytes() + for chunk in import_file.chunks(): + data += chunk + + file_storage.save(data, input_format.get_read_mode()) + return file_storage + + +def get_supported_extensions(): + return ("csv", "tsv", "xls", "xlsx") + + +def get_format_cls_by_extension(extension): + formats = get_import_formats() + + available_formats = [x for x in formats if x.__name__ == extension.upper()] + + if not available_formats: + return None + + return available_formats[0] + + +def get_import_formats(): + formats = [f for f in DEFAULT_FORMATS if f().can_import()] + return formats + + +def get_file_storage(): + file_storage = getattr( + settings, 'WAGTAIL_REDIRECTS_FILE_STORAGE', 'tmp_file' + ) + if file_storage == 'tmp_file': + return TempFolderStorage + if file_storage == 'cache': + return RedirectsCacheStorage + + raise Exception( + "Invalid file storage, must be either 'tmp_file' or 'cache'" + ) + + +class RedirectsCacheStorage(CacheStorage): + CACHE_PREFIX = 'wagtail-redirects-' diff --git a/wagtail/contrib/redirects/views.py b/wagtail/contrib/redirects/views.py index 16ee65dc99..0f16e1352e 100644 --- a/wagtail/contrib/redirects/views.py +++ b/wagtail/contrib/redirects/views.py @@ -1,17 +1,26 @@ +import os + from django.core.paginator import Paginator from django.db.models import Q -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.template.response import TemplateResponse from django.urls import reverse +from django.utils.encoding import force_str from django.utils.translation import gettext as _ +from django.utils.translation import ngettext +from django.views.decorators.http import require_http_methods from django.views.decorators.vary import vary_on_headers from wagtail.admin import messages from wagtail.admin.auth import PermissionPolicyChecker, permission_denied from wagtail.admin.forms.search import SearchForm from wagtail.contrib.redirects import models -from wagtail.contrib.redirects.forms import RedirectForm +from wagtail.contrib.redirects.base_formats import DEFAULT_FORMATS +from wagtail.contrib.redirects.forms import ConfirmImportForm, ImportForm, RedirectForm from wagtail.contrib.redirects.permissions import permission_policy +from wagtail.contrib.redirects.utils import ( + get_file_storage, get_format_cls_by_extension, get_import_formats, get_supported_extensions, + write_to_file_storage) permission_checker = PermissionPolicyChecker(permission_policy) @@ -126,3 +135,215 @@ def add(request): return TemplateResponse(request, "wagtailredirects/add.html", { 'form': form, }) + + +@permission_checker.require_any("add") +def start_import(request): + supported_extensions = get_supported_extensions() + from_encoding = "utf-8" + + query_string = request.GET.get('q', "") + + if request.POST or request.FILES: + form_kwargs = {} + form = ImportForm( + supported_extensions, + request.POST or None, + request.FILES or None, + **form_kwargs + ) + else: + form = ImportForm(supported_extensions) + + if not request.FILES or not form.is_valid(): + return render( + request, + "wagtailredirects/choose_import_file.html", + { + 'search_form': SearchForm( + data=dict(q=query_string) if query_string else None, placeholder=_("Search redirects") + ), + "form": form, + }, + ) + + import_file = form.cleaned_data["import_file"] + + _name, extension = os.path.splitext(import_file.name) + extension = extension.lstrip(".") + + if extension not in supported_extensions: + messages.error( + request, + _('File format of type "{}" is not supported'.format(extension)) + ) + return redirect('wagtailredirects:start_import') + + import_format_cls = get_format_cls_by_extension(extension) + input_format = import_format_cls() + file_storage = write_to_file_storage(import_file, input_format) + + try: + data = file_storage.read(input_format.get_read_mode()) + if not input_format.is_binary() and from_encoding: + data = force_str(data, from_encoding) + dataset = input_format.create_dataset(data) + except UnicodeDecodeError as e: + messages.error( + request, + _(u"Imported file has a wrong encoding: %s" % e) + ) + return redirect('wagtailredirects:start_import') + except Exception as e: # pragma: no cover + messages.error( + request, + _( + u"%s encountered while trying to read file: %s" + % (type(e).__name__, import_file.name) + ) + ) + return redirect('wagtailredirects:start_import') + + initial = { + "import_file_name": file_storage.name, + "original_file_name": import_file.name, + "input_format": get_import_formats().index(import_format_cls), + } + + return render( + request, + "wagtailredirects/confirm_import.html", + { + "form": ConfirmImportForm(dataset.headers, initial=initial), + "dataset": dataset, + }, + ) + + +@permission_checker.require_any("add") +@require_http_methods(["POST"]) +def process_import(request): + supported_extensions = get_supported_extensions() + from_encoding = "utf-8" + + form_kwargs = {} + form = ConfirmImportForm( + DEFAULT_FORMATS, request.POST or None, request.FILES or None, **form_kwargs + ) + + is_confirm_form_valid = form.is_valid() + + import_formats = get_import_formats() + input_format = import_formats[int(form.cleaned_data["input_format"])]() + + FileStorage = get_file_storage() + file_storage = FileStorage(name=form.cleaned_data["import_file_name"]) + + if not is_confirm_form_valid: + data = file_storage.read(input_format.get_read_mode()) + if not input_format.is_binary() and from_encoding: + data = force_str(data, from_encoding) + dataset = input_format.create_dataset(data) + + initial = { + "import_file_name": file_storage.name, + "original_file_name": form.cleaned_data["import_file_name"], + } + + return render( + request, + "wagtailredirects/confirm_import.html", + { + "form": ConfirmImportForm( + dataset.headers, + request.POST or None, + request.FILES or None, + initial=initial, + ), + "dataset": dataset, + }, + ) + + data = file_storage.read(input_format.get_read_mode()) + if not input_format.is_binary() and from_encoding: + data = force_str(data, from_encoding) + dataset = input_format.create_dataset(data) + + import_summary = create_redirects_from_dataset( + dataset, + { + "from_index": int(form.cleaned_data["from_index"]), + "to_index": int(form.cleaned_data["to_index"]), + "permanent": form.cleaned_data["permanent"], + "site": form.cleaned_data["site"], + }, + ) + + file_storage.remove() + + if import_summary["errors_count"] > 0: + return render( + request, + "wagtailredirects/import_summary.html", + { + "form": ImportForm(supported_extensions), + "import_summary": import_summary, + }, + ) + + total = import_summary["total"] + messages.success( + request, + ngettext( + "Imported %(total)d redirect", + "Imported %(total)d redirects", + total + ) % {'total': total} + ) + + return redirect('wagtailredirects:index') + + +def create_redirects_from_dataset(dataset, config): + errors = [] + successes = 0 + total = 0 + + for row in dataset: + total += 1 + + from_link = row[config["from_index"]] + to_link = row[config["to_index"]] + + data = { + "old_path": from_link, + "redirect_link": to_link, + "is_permanent": config["permanent"], + } + + if config["site"]: + data["site"] = config["site"].pk + + form = RedirectForm(data) + if not form.is_valid(): + error = to_readable_errors(form.errors.as_text()) + errors.append([from_link, to_link, error]) + continue + + form.save() + successes += 1 + + return { + "errors": errors, + "errors_count": len(errors), + "successes": successes, + "total": total, + } + + +def to_readable_errors(error): + errors = error.split("\n") + errors = errors[1::2] + errors = [x.lstrip('* ') for x in errors] + errors = ", ".join(errors) + return errors